@tablation/crew 0.24.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
@@ -100,6 +106,9 @@ routes:
100
106
  # Only needed for a self-hosted tracker.
101
107
  # baseUrl: "https://app.tablation.com"
102
108
  apiKey: "REPLACE_ME" # or omit if `ship.apiKey` covers this workspace too
109
+ # With no apiKey (and no apiKeyFile/apiKeyVar) crew reads CREW_API_KEY from
110
+ # its process environment. Under launchd that must be set in the job's own
111
+ # environment, not exported from an interactive shell.
103
112
 
104
113
  # Where release state should be SHOWN is a fact about this machine and the
105
114
  # person watching it, not about the code — so unlike every other hook,
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 {
@@ -1065,8 +1108,10 @@ function resolveApiKey(r) {
1065
1108
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1066
1109
  }
1067
1110
  }
1111
+ const fromEnv = process.env.CREW_API_KEY?.trim();
1112
+ if (fromEnv) return fromEnv;
1068
1113
  throw new ConfigError(
1069
- `route "${r.route}": no API key (apiKey, or ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"})`
1114
+ `route "${r.route}": no API key (looked for, in order: apiKey; ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"}; CREW_API_KEY in the environment)`
1070
1115
  );
1071
1116
  }
1072
1117
  var KEYCHAIN_ELIGIBLE_ROUTES = /* @__PURE__ */ new WeakSet();
@@ -1475,6 +1520,7 @@ var State = class _State {
1475
1520
  const failed = `.deploy-failed-sha-${suffix}`;
1476
1521
  const gateFailed = `.test-gate-failed-${suffix}`;
1477
1522
  const gateReported = `.test-gate-reported-${suffix}`;
1523
+ const heldReported = `.release-held-${suffix}`;
1478
1524
  const read = (f) => {
1479
1525
  try {
1480
1526
  return readFileSync3(this.path(f), "utf8").trim() || null;
@@ -1525,6 +1571,20 @@ var State = class _State {
1525
1571
  rmSync(this.path(gateFailed), { force: true });
1526
1572
  },
1527
1573
  /** Whether the board was already told about this head's red gate. */
1574
+ heldReported: () => {
1575
+ try {
1576
+ return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1577
+ } catch {
1578
+ return null;
1579
+ }
1580
+ },
1581
+ noteHeldReported: (signature) => {
1582
+ writeFileSync(this.path(heldReported), `${signature}
1583
+ `);
1584
+ },
1585
+ clearHeldReported: () => {
1586
+ rmSync(this.path(heldReported), { force: true });
1587
+ },
1528
1588
  testGateReported: (sha) => read(gateReported) === sha,
1529
1589
  noteTestGateReported: (sha) => {
1530
1590
  writeFileSync(this.path(gateReported), `${sha}
@@ -3934,15 +3994,18 @@ function blockerInfoMap(tickets, extra = []) {
3934
3994
  }
3935
3995
  function computeBlockedIds(tickets, info) {
3936
3996
  const blocked = /* @__PURE__ */ new Set();
3937
- for (const t of tickets) {
3938
- const unresolved = (t.blocked_by ?? []).some((id) => {
3939
- const status2 = info[id]?.status;
3940
- return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
3941
- });
3942
- if (unresolved) blocked.add(t.id);
3943
- }
3997
+ for (const t of tickets) if (unresolvedBlockers(t, info).length > 0) blocked.add(t.id);
3944
3998
  return blocked;
3945
3999
  }
4000
+ function unresolvedBlockers(t, info) {
4001
+ return (t.blocked_by ?? []).filter((id) => {
4002
+ const status2 = info[id]?.status;
4003
+ return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
4004
+ });
4005
+ }
4006
+ function describeUnresolved(t, info) {
4007
+ return unresolvedBlockers(t, info).map((id) => `${info[id]?.issue_tag ?? info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
4008
+ }
3946
4009
  function sweepDiagnostics(tickets, info) {
3947
4010
  const dangling = [];
3948
4011
  const selfBlocked = [];
@@ -4258,9 +4321,12 @@ function buildingRoleHasWork(role, i) {
4258
4321
  }
4259
4322
  return { hasWork: false, reason: "nothing startable and no new comments" };
4260
4323
  }
4324
+ function qaCandidates(i) {
4325
+ return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds) && !i.blocked.has(t.id));
4326
+ }
4261
4327
  function qaRoleHasWork(i) {
4262
4328
  if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
4263
- const n = withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds)).length;
4329
+ const n = qaCandidates(i).length;
4264
4330
  return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
4265
4331
  }
4266
4332
  function triageRoleHasWork(i) {
@@ -4276,7 +4342,7 @@ function roleHasWork(role, i) {
4276
4342
  return buildingRoleHasWork(role, i);
4277
4343
  }
4278
4344
  function roleCandidates(role, i) {
4279
- if (role === "qa") return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds));
4345
+ if (role === "qa") return qaCandidates(i);
4280
4346
  if (role === "triage") return triageSlice(i.tickets, i.seats.triage, i.workModes).filter((t) => !isHeld(t, i.holds));
4281
4347
  if (role === "pair") return [];
4282
4348
  const me = i.seats[role];
@@ -4610,19 +4676,35 @@ function qaDigest(i) {
4610
4676
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4611
4677
  return `| ${displayKey2(t)} | ${repo} | ${modeCell(t, i)} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
4612
4678
  };
4679
+ const waiting = i.tickets.filter((t) => (t.status === "qa" || t.status === "fixed") && i.blocked.has(t.id));
4680
+ const takeable = (status2) => i.tickets.filter((t) => t.status === status2 && !i.blocked.has(t.id));
4681
+ const waitingTable = waiting.length === 0 ? "" : `
4682
+ ### Waiting on blockers \u2014 not yours to verify yet
4683
+
4684
+ A \`fixed\` or \`qa\` ticket whose \`Blocked by\` names something not yet resolved
4685
+ (\`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`) is held out of
4686
+ verification: its change needs the blocker released first. It becomes yours on
4687
+ the first poll after the last blocker resolves. No one needs to change the ticket.
4688
+
4689
+ | ticket | status | blocked by |
4690
+ |---|---|---|
4691
+ ${waiting.map((t) => `| ${displayKey2(t)} | ${t.status} | ${blockers(t, i)} |`).join("\n")}
4692
+ `;
4613
4693
  return stream([
4614
4694
  "## Current queue \u2014 built for you by the poll\n",
4615
4695
  '\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
4616
4696
  '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
4617
4697
  "\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
4618
4698
  "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
4619
- table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
4699
+ table(takeable("qa"), i, header, row),
4620
4700
  "\n### Awaiting verification, in pick order\n",
4621
- table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
4701
+ table(takeable("fixed"), i, header, row),
4702
+ ...waitingTable ? [waitingTable] : []
4622
4703
  ]);
4623
4704
  }
4624
4705
 
4625
4706
  // src/ticket-branch.ts
4707
+ import { basename as basename4 } from "path";
4626
4708
  function branchRenderer(t, role) {
4627
4709
  const { tag, prefix } = ticketBranchContext(t);
4628
4710
  return (template) => renderBranchName(template, { key: t.issue_id, title: t.title ?? void 0, role, tag, prefix });
@@ -4631,7 +4713,15 @@ function worktreeForTicket(dir, cfg, t, role) {
4631
4713
  const { prefix } = ticketBranchContext(t);
4632
4714
  const branchName = branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
4633
4715
  const number = t.issue_id.replace(/^\D+/, "");
4634
- 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
+ }));
4635
4725
  }
4636
4726
  function branchTemplates(cfg, t) {
4637
4727
  const { prefix } = ticketBranchContext(t);
@@ -4947,7 +5037,9 @@ function writeDigest(o, d, role, stateDir) {
4947
5037
  const repoFor = (dir) => {
4948
5038
  const hit = repoCache.get(dir);
4949
5039
  if (hit) return hit;
4950
- 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);
4951
5043
  repoCache.set(dir, cfg);
4952
5044
  return cfg;
4953
5045
  };
@@ -5312,7 +5404,7 @@ import { join as join7, dirname as dirname5 } from "path";
5312
5404
  import { createHash as createHash2 } from "crypto";
5313
5405
 
5314
5406
  // src/environment.ts
5315
- import { basename as basename4 } from "path";
5407
+ import { basename as basename5 } from "path";
5316
5408
  var API_KEY_VAR = "CREW_API_KEY";
5317
5409
  function authorshipBullets(a, contract) {
5318
5410
  if (!a) return [];
@@ -5363,8 +5455,16 @@ function repoSection(r, key, heading) {
5363
5455
  const repo = r.config;
5364
5456
  const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
5365
5457
  const branchName = branchExample(repo, key);
5366
- const worktreeName = effectiveWorktreeDirName(repo, r.dir, branchName, key.replace(/^\D+/, ""));
5367
- 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>`);
5368
5468
  const lines = [
5369
5469
  "",
5370
5470
  heading,
@@ -6337,7 +6437,8 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6337
6437
  const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
6338
6438
  hooks: { ...route.hooks, ...o?.hooks },
6339
6439
  labels: { ...route.labels, ...o?.labels },
6340
- branch: { ...route.branch, ...o?.branch }
6440
+ branch: { ...route.branch, ...o?.branch },
6441
+ worktrees: { nameOverride: o?.worktreeName }
6341
6442
  }, target.dir);
6342
6443
  if (role === "qa") {
6343
6444
  await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
@@ -6562,10 +6663,16 @@ function checkGuards(cwd, base = "main") {
6562
6663
  detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
6563
6664
  };
6564
6665
  }
6666
+ function heldByBlockers(tickets, verifiedStatus, info) {
6667
+ if (!info) return [];
6668
+ return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info) }));
6669
+ }
6670
+ var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
6565
6671
  var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
6566
6672
  var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
6567
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main") {
6568
- const verified = tickets.filter((t) => t.status === contract.statuses.verified).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
6673
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo) {
6674
+ const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
6675
+ const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
6569
6676
  return verified.map((ticket) => {
6570
6677
  const branch = branchFor(ticket);
6571
6678
  if (!branch) {
@@ -6683,12 +6790,13 @@ function decideRelease(cwd, tickets, contract, opts = {}) {
6683
6790
  const lastTag = latestReleaseTag(cwd, pattern, "HEAD");
6684
6791
  const lastReleased = lastTag ? tagCommit(cwd, lastTag) : null;
6685
6792
  const head = resolve2(cwd, "HEAD") ?? "";
6686
- const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base);
6793
+ const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base, opts.blockerInfo);
6687
6794
  const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..HEAD`) : 0;
6688
6795
  const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..HEAD`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
6689
6796
  return {
6690
6797
  block,
6691
6798
  merges,
6799
+ held: heldByBlockers(tickets, contract.statuses.verified, opts.blockerInfo),
6692
6800
  head,
6693
6801
  lastTag,
6694
6802
  lastReleased,
@@ -7293,6 +7401,17 @@ function openReleaseCheckout(o) {
7293
7401
  }
7294
7402
  return { dir, branch, close };
7295
7403
  }
7404
+ function reportHeld(o, held) {
7405
+ if (held.length === 0) {
7406
+ o.state?.clearHeldReported?.();
7407
+ return;
7408
+ }
7409
+ const lines = describeHeld(held);
7410
+ const signature = lines.join("\n");
7411
+ if (o.dryRun || o.state?.heldReported?.() === signature) return;
7412
+ for (const line of lines) o.emit.emit(line, { step: "release" });
7413
+ o.state?.noteHeldReported?.(signature);
7414
+ }
7296
7415
  async function runRelease(o) {
7297
7416
  const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
7298
7417
  const tagPattern = o.repo.release.tagPattern ?? "v*";
@@ -7301,11 +7420,13 @@ async function runRelease(o) {
7301
7420
  const decision = decideRelease(o.cwd, o.tickets, o.contract, {
7302
7421
  tagPattern,
7303
7422
  base: o.repo.branch.base,
7423
+ blockerInfo: o.blockerInfo,
7304
7424
  // CREW-1364: a branch another ship built and pushed at `fixed` exists
7305
7425
  // here only on the remote. Materialize it so the squash below has a ref.
7306
7426
  // A dry run only looks.
7307
7427
  branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
7308
7428
  });
7429
+ reportHeld(o, decision.held);
7309
7430
  if (!fresh.ok) {
7310
7431
  o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
7311
7432
  return { merged: [], conflicts: [], deployed: false, stopped: fresh.why, decision };
@@ -8974,7 +9095,7 @@ async function showLogEntry(route, entryId, opts = {}) {
8974
9095
 
8975
9096
  // src/repos-cmd.ts
8976
9097
  import { homedir as homedir3 } from "os";
8977
- import { basename as basename5, resolve as resolve3 } from "path";
9098
+ import { basename as basename6, resolve as resolve3 } from "path";
8978
9099
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
8979
9100
  var ReposError = class extends Error {
8980
9101
  };
@@ -9046,7 +9167,7 @@ function configuredRepos(text, route, base) {
9046
9167
  });
9047
9168
  }
9048
9169
  const dir = m.get("dir");
9049
- return dir ? [{ name: basename5(String(dir)), dir: expandDir(String(dir), base) }] : [];
9170
+ return dir ? [{ name: basename6(String(dir)), dir: expandDir(String(dir), base) }] : [];
9050
9171
  }
9051
9172
  function planRepoAdd(i) {
9052
9173
  const doc = parseDocument(i.text);
@@ -9058,7 +9179,7 @@ function planRepoAdd(i) {
9058
9179
  throw new ReposError(`${target} is already configured on route ${i.route}`);
9059
9180
  }
9060
9181
  const matched = matchRepoName(i.origin, i.resolved);
9061
- 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);
9062
9183
  let warning;
9063
9184
  if (!matched) {
9064
9185
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -9222,14 +9343,14 @@ function schedulerInstalled(plan, exists, crontab) {
9222
9343
 
9223
9344
  // src/worktree-sweep.ts
9224
9345
  import { existsSync as existsSync15 } from "fs";
9225
- import { basename as basename6 } from "path";
9346
+ import { basename as basename7 } from "path";
9226
9347
  function planWorktreeSweep(target, tickets, contract) {
9227
9348
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
9228
9349
  const verified = contract.statuses.verified;
9229
9350
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
9230
9351
  const actions = [];
9231
9352
  for (const w of worktrees(target.dir)) {
9232
- const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
9353
+ const m = /(?:^|-)(\d+)$/.exec(basename7(w.path));
9233
9354
  if (!m) continue;
9234
9355
  const ticket = byNumber.get(m[1]);
9235
9356
  if (!ticket) continue;
@@ -10432,7 +10553,8 @@ async function buildEnvironment(route, ship, ticket) {
10432
10553
  config: resolveRepoConfig(loadRepoConfig(t.dir), {
10433
10554
  hooks: { ...route.hooks, ...o?.hooks },
10434
10555
  labels: { ...route.labels, ...o?.labels },
10435
- branch: { ...route.branch, ...o?.branch }
10556
+ branch: { ...route.branch, ...o?.branch },
10557
+ worktrees: { nameOverride: o?.worktreeName }
10436
10558
  }, t.dir)
10437
10559
  };
10438
10560
  });
@@ -11027,7 +11149,7 @@ ${changed2.map((c) => ` - ${c}
11027
11149
  // that declares its own `worktrees.prefix` shadows either, and
11028
11150
  // `doctor` reports that the way it reports every other shadowed
11029
11151
  // setting.
11030
- worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id) }
11152
+ worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id), nameOverride: o?.worktreeName }
11031
11153
  }, t.dir)
11032
11154
  };
11033
11155
  }));
@@ -11084,7 +11206,7 @@ ${changed2.map((c) => ` - ${c}
11084
11206
  labels: { ...c.labels, ...o?.labels },
11085
11207
  release: mergeRouteRelease(c.release, o?.release),
11086
11208
  branch: { ...c.branch, ...o?.branch },
11087
- worktrees: { prefix: shipWorktreePrefixFor(c) }
11209
+ worktrees: { prefix: shipWorktreePrefixFor(c), nameOverride: o?.worktreeName }
11088
11210
  }, target.dir);
11089
11211
  const problems = validateEffective(repo);
11090
11212
  for (const p of problems) remit.warn(`${scope}: ${p}`);
@@ -11166,11 +11288,13 @@ ${changed2.map((c) => ` - ${c}
11166
11288
  releaseModes
11167
11289
  );
11168
11290
  reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
11291
+ const blockerInfo = blockerInfoMap(all, await tracker.ticketsByIds(missingBlockerIds(all)));
11169
11292
  const outcome = await runRelease({
11170
11293
  cwd: target.dir,
11171
11294
  repo,
11172
11295
  contract: tracker.contract,
11173
11296
  tickets,
11297
+ blockerInfo,
11174
11298
  emit: remit,
11175
11299
  scope,
11176
11300
  state: state.release(scope),
@@ -13135,7 +13259,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13135
13259
  // `synthesis-tabl-123`. `worktreePrefix` above is kept for
13136
13260
  // config-provenance display; this is the field that actually
13137
13261
  // describes where a worktree lands by default.
13138
- 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>`),
13139
13263
  worktreeParent: resolvePath(r.dir, "..")
13140
13264
  })),
13141
13265
  waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
@@ -13187,6 +13311,17 @@ watermark: ${state.watermark()}
13187
13311
  `);
13188
13312
  process.stdout.write(`releaser: ${r.name} \u2014 ${await releaserLine(statusTracker, route.route, r.name, r.config.release)}
13189
13313
  `);
13314
+ }
13315
+ try {
13316
+ const open = await statusTracker.openTickets();
13317
+ const held = heldByBlockers(
13318
+ open,
13319
+ statusTracker.contract.statuses.verified,
13320
+ blockerInfoMap(open, await statusTracker.ticketsByIds(missingBlockerIds(open)))
13321
+ );
13322
+ for (const line of describeHeld(held)) process.stdout.write(`release: ${line}
13323
+ `);
13324
+ } catch {
13190
13325
  }
13191
13326
  if (waiting) {
13192
13327
  process.stdout.write(
@@ -13223,10 +13358,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
13223
13358
  );
13224
13359
  for (const r of await resolvedRepos(route)) {
13225
13360
  const { config } = r;
13226
- 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)}`);
13227
13362
  process.stdout.write(
13228
13363
  `${`repo ${r.name}:`.padEnd(19)}${r.dir}
13229
- 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}
13230
13365
  requires ${config.platform} \u2014 ${satisfies(host, config.platform) ? "OK" : "MISMATCH"}
13231
13366
  `
13232
13367
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.24.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",
@@ -684,9 +684,42 @@ post. They record who filed the ticket and from where. The block lists a column
684
684
  only when this workspace has it, so a column it does not name is not yours to set.
685
685
 
686
686
  Name the ticket you were working when you noticed it in the new ticket's
687
- description. Only put it in the new ticket's `blocked by` column if your
688
- own ticket genuinely cannot proceed without the new one being fixed
689
- first — merely having noticed it nearby is not a dependency.
687
+ description. Only add the new ticket to **your own ticket's** `blocked_by`
688
+ (the column named `blocked by` in the Environment section) if your own
689
+ ticket genuinely cannot proceed without the new one being fixed first —
690
+ merely having noticed it nearby is not a dependency. The dependency always
691
+ points from the ticket that waits to the ticket it waits on, never the
692
+ other way round.
693
+
694
+ ### Work your ticket needs in another repository
695
+
696
+ Out-of-scope breakage and **in-scope work that lives somewhere else** are
697
+ different things. If your ticket's acceptance depends on a change in another
698
+ repository (or on a change this repository cannot make), do not ship a
699
+ stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
700
+ docs or hints promise behaviour the dependency has not delivered is a defect
701
+ that QA passes and the release phase ships. Instead:
702
+
703
+ 1. File that work as its own ticket in the owning repository's project (the
704
+ Environment section names the projects and repos; if the owning repo is
705
+ not listed there, file it against the project and say which repo in the
706
+ description), setting `repo_id`, `project_id`, `filed_by_id` and
707
+ `ship_id` as in the filing block, and naming your ticket in its
708
+ description.
709
+ 2. Add the new ticket's id to **your own ticket's** `blocked_by`.
710
+ 3. Post a comment on your ticket naming each dependency and what it must
711
+ deliver.
712
+ 4. Set your ticket back to `accepted`. The poll's sweep parks it at
713
+ `blocked` and restores it to `accepted` once every blocker resolves.
714
+ In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
715
+ hybrid ticket is off limits to every lane); in an `automatic` project
716
+ clear it as for any other re-queue.
717
+ 5. Leave the branch and worktree in place, committed and pushed if they hold
718
+ work, so the lane that resumes the ticket continues from them.
719
+
720
+ Hand a ticket off as `fixed` only when the branch works against
721
+ dependencies that are already released. A hand-off comment that says "worth
722
+ separate tickets" for work the ticket needs is a hand-off that is not ready.
690
723
 
691
724
  ## Step 4 — you never merge, and you never deploy
692
725
 
@@ -82,6 +82,12 @@ stopping for this run, do the following instead:
82
82
  is actually blocked on the operator's input, not as a substitute for
83
83
  the ordinary ending above.
84
84
 
85
+ Before you post, check the comment: it must not name needed-but-unfiled
86
+ work. A design that depends on a change in another repository files that
87
+ ticket and links it in your ticket's `blocked_by` (see "Work your ticket
88
+ needs in another repository"), rather than listing it as a loose
89
+ follow-up.
90
+
85
91
  Either way, stop this ticket's work for this run once the flag is set and
86
92
  the comment posted. The operator reviews what you posted and takes it
87
93
  from there:
@@ -24,4 +24,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
24
24
  with a comment saying what still fails. Take that bounce seriously — it is
25
25
  the only reading your work gets before it is deployed.
26
26
 
27
- **No extra steps.** Follow the shared policy as written.
27
+ **Before you hand a ticket to QA, check the hand-off comment.** It must not
28
+ name needed-but-unfiled work: anything the ticket's acceptance depends on in
29
+ another repository is a filed ticket in your ticket's `blocked_by` (see "Work
30
+ your ticket needs in another repository"), not a line saying "worth separate
31
+ tickets". If it would name such work, the ticket goes back to `accepted`
32
+ instead of forward to `fixed`.
33
+
34
+ **No extra steps.** Otherwise follow the shared policy as written.
@@ -117,6 +117,13 @@ human review, so "probably fine" is not a pass.
117
117
  Missing or stale docs are a reason to bounce the ticket back to the dev
118
118
  seat, with a comment naming the page that needs the change. If the
119
119
  repository names no such location, skip this check.
120
+ 7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
121
+ comment names work that is needed in another repository (or that this
122
+ repository cannot do) and no ticket for it exists in the ticket's
123
+ `blocked_by`, that is a bounce: say so in your comment and ask for the
124
+ ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
125
+ hints promise behaviour the dependency has not yet delivered is a defect
126
+ even if every test passes. Bounce it like any other failure (step 8).
120
127
  7a. **Ask whether the fix is covered.** A behaviour change with no test that
121
128
  would catch its regression is worth naming in your comment; whether it
122
129
  is worth bouncing the ticket over is your judgement, and depends on how
@@ -666,9 +666,42 @@ post. They record who filed the ticket and from where. The block lists a column
666
666
  only when this workspace has it, so a column it does not name is not yours to set.
667
667
 
668
668
  Name the ticket you were working when you noticed it in the new ticket's
669
- description. Only put it in the new ticket's `blocked by` column if your
670
- own ticket genuinely cannot proceed without the new one being fixed
671
- first — merely having noticed it nearby is not a dependency.
669
+ description. Only add the new ticket to **your own ticket's** `blocked_by`
670
+ (the column named `blocked by` in the Environment section) if your own
671
+ ticket genuinely cannot proceed without the new one being fixed first —
672
+ merely having noticed it nearby is not a dependency. The dependency always
673
+ points from the ticket that waits to the ticket it waits on, never the
674
+ other way round.
675
+
676
+ ### Work your ticket needs in another repository
677
+
678
+ Out-of-scope breakage and **in-scope work that lives somewhere else** are
679
+ different things. If your ticket's acceptance depends on a change in another
680
+ repository (or on a change this repository cannot make), do not ship a
681
+ stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
682
+ docs or hints promise behaviour the dependency has not delivered is a defect
683
+ that QA passes and the release phase ships. Instead:
684
+
685
+ 1. File that work as its own ticket in the owning repository's project (the
686
+ Environment section names the projects and repos; if the owning repo is
687
+ not listed there, file it against the project and say which repo in the
688
+ description), setting `repo_id`, `project_id`, `filed_by_id` and
689
+ `ship_id` as in the filing block, and naming your ticket in its
690
+ description.
691
+ 2. Add the new ticket's id to **your own ticket's** `blocked_by`.
692
+ 3. Post a comment on your ticket naming each dependency and what it must
693
+ deliver.
694
+ 4. Set your ticket back to `accepted`. The poll's sweep parks it at
695
+ `blocked` and restores it to `accepted` once every blocker resolves.
696
+ In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
697
+ hybrid ticket is off limits to every lane); in an `automatic` project
698
+ clear it as for any other re-queue.
699
+ 5. Leave the branch and worktree in place, committed and pushed if they hold
700
+ work, so the lane that resumes the ticket continues from them.
701
+
702
+ Hand a ticket off as `fixed` only when the branch works against
703
+ dependencies that are already released. A hand-off comment that says "worth
704
+ separate tickets" for work the ticket needs is a hand-off that is not ready.
672
705
 
673
706
  ## Step 4 — you never merge, and you never deploy
674
707
 
@@ -23,4 +23,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
23
23
  with a comment saying what still fails. Take that bounce seriously — it is
24
24
  the only reading your work gets before it is deployed.
25
25
 
26
- **No extra steps.** Follow the shared policy as written.
26
+ **Before you hand a ticket to QA, check the hand-off comment.** It must not
27
+ name needed-but-unfiled work: anything the ticket's acceptance depends on in
28
+ another repository is a filed ticket in your ticket's `blocked_by` (see "Work
29
+ your ticket needs in another repository"), not a line saying "worth separate
30
+ tickets". If it would name such work, the ticket goes back to `accepted`
31
+ instead of forward to `fixed`.
32
+
33
+ **No extra steps.** Otherwise follow the shared policy as written.
@@ -104,6 +104,13 @@ human review, so "probably fine" is not a pass.
104
104
  Missing or stale docs are a reason to bounce the ticket back to the dev
105
105
  seat, with a comment naming the page that needs the change. If the
106
106
  repository names no such location, skip this check.
107
+ 7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
108
+ comment names work that is needed in another repository (or that this
109
+ repository cannot do) and no ticket for it exists in the ticket's
110
+ `blocked_by`, that is a bounce: say so in your comment and ask for the
111
+ ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
112
+ hints promise behaviour the dependency has not yet delivered is a defect
113
+ even if every test passes. Bounce it like any other failure (step 8).
107
114
  7a. **Ask whether the fix is covered.** A behaviour change with no test that
108
115
  would catch its regression is worth naming in your comment; whether it
109
116
  is worth bouncing the ticket over is your judgement, and depends on how