@tablation/crew 0.17.0 → 0.18.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +33 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -206,6 +206,7 @@ crew doctor [route] [--fix] preflight; --fix enables clean routes, offers cre
206
206
  crew ports [route] which checkout owns which ports, and what is up
207
207
  crew reap [route] kill servers left behind by removed worktrees
208
208
  crew drop [route] NNN remove a merged ticket's worktree and branch
209
+ crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
209
210
  crew sync [route] fast-forward the checkout and its worktrees from the remote
210
211
  crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
211
212
  crew log [route] tail the log
package/dist/cli.js CHANGED
@@ -6010,11 +6010,18 @@ function applyRunWorktree(dir, cfg, plan) {
6010
6010
  return plan.path;
6011
6011
  }
6012
6012
  }
6013
+ function worktreeHoldsUnpushed(path, remote) {
6014
+ const out = gitOk(path, ["for-each-ref", "--contains", "HEAD", "--count=1", "--format=%(refname)", `refs/remotes/${remote}/`]);
6015
+ return out === null || out.trim() === "";
6016
+ }
6013
6017
  async function finishRunWorktree(dir, cfg, branch) {
6014
6018
  const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
6015
6019
  if (!wt || !existsSync9(wt.path)) return { kind: "none" };
6016
6020
  if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
6017
6021
  if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
6022
+ if (remoteConfigured(dir, cfg.branch.remote) && worktreeHoldsUnpushed(wt.path, cfg.branch.remote)) {
6023
+ return { kind: "kept-unpushed", path: wt.path };
6024
+ }
6018
6025
  const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
6019
6026
  if (Number.isFinite(n)) {
6020
6027
  const pids = pidsInWorktree(wt.path, n);
@@ -6114,7 +6121,7 @@ async function enforceHandoff(i) {
6114
6121
  }
6115
6122
  }
6116
6123
  async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6117
- if (role !== "dev" && role !== "design" || !workingId) return null;
6124
+ if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
6118
6125
  const seatId = route.resolved?.seats[role];
6119
6126
  if (!seatId) return null;
6120
6127
  try {
@@ -6127,6 +6134,10 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
6127
6134
  labels: { ...route.labels, ...o?.labels },
6128
6135
  branch: { ...route.branch, ...o?.branch }
6129
6136
  }, target.dir);
6137
+ if (role === "qa") {
6138
+ await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
6139
+ return null;
6140
+ }
6130
6141
  const affinity = await tracker.claimAffinity();
6131
6142
  const outcome = await enforceHandoff({
6132
6143
  writer: tracker,
@@ -6158,6 +6169,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
6158
6169
  log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
6159
6170
  await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId);
6160
6171
  } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
6172
+ else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
6161
6173
  } catch (e) {
6162
6174
  log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
6163
6175
  }
@@ -8936,18 +8948,22 @@ import { existsSync as existsSync15 } from "fs";
8936
8948
  import { basename as basename6 } from "path";
8937
8949
  function planWorktreeSweep(target, tickets, contract) {
8938
8950
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
8951
+ const verified = contract.statuses.verified;
8939
8952
  const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
8940
8953
  const actions = [];
8941
8954
  for (const w of worktrees(target.dir)) {
8942
8955
  const m = /(?:^|-)(\d+)$/.exec(basename6(w.path));
8943
8956
  if (!m) continue;
8944
8957
  const ticket = byNumber.get(m[1]);
8945
- if (!ticket || !terminal.has(ticket.status)) continue;
8958
+ if (!ticket) continue;
8959
+ const isVerified = ticket.status === verified;
8960
+ if (!isVerified && !terminal.has(ticket.status)) continue;
8946
8961
  actions.push({
8947
8962
  ticket,
8948
8963
  path: w.path,
8949
8964
  branch: w.branch,
8950
- keepBranch: ticket.status !== contract.statuses.deployed
8965
+ keepBranch: ticket.status !== contract.statuses.deployed,
8966
+ ...isVerified ? { requirePushed: true } : {}
8951
8967
  });
8952
8968
  }
8953
8969
  return actions;
@@ -8970,6 +8986,13 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
8970
8986
  continue;
8971
8987
  }
8972
8988
  }
8989
+ if (!dryRun && a.requirePushed && a.branch && existsSync15(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
8990
+ log.warn(
8991
+ `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): ${a.branch} has commits that are not on ${remote}`,
8992
+ { ticket: a.ticket.issue_id, step: "worktree" }
8993
+ );
8994
+ continue;
8995
+ }
8973
8996
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
8974
8997
  const pids = pidsInWorktree(a.path, n);
8975
8998
  if (pids.length) {
@@ -10966,8 +10989,9 @@ ${changed2.map((c) => ` - ${c}
10966
10989
  }
10967
10990
  try {
10968
10991
  const terminal = await tracker.terminalTickets();
10992
+ const verifiedNow = (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified);
10969
10993
  const scoped = ticketsByRepo(c, terminal).byRepo.get(target.name) ?? [];
10970
- const actions = planWorktreeSweep(target, scoped, tracker.contract);
10994
+ const actions = planWorktreeSweep(target, [...scoped, ...ticketsByRepo(c, verifiedNow).byRepo.get(target.name) ?? []], tracker.contract);
10971
10995
  if (actions.length) {
10972
10996
  remit.enter("worktree");
10973
10997
  const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
@@ -12344,8 +12368,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12344
12368
  const tracker = new Tracker(route, cfg.ship);
12345
12369
  const terminal = await tracker.terminalTickets();
12346
12370
  const { byRepo } = ticketsByRepo(route, terminal);
12371
+ const verifiedByRepo = ticketsByRepo(
12372
+ route,
12373
+ (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified)
12374
+ ).byRepo;
12347
12375
  for (const r of await resolvedRepos(route)) {
12348
- const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
12376
+ const actions = planWorktreeSweep(r, [...byRepo.get(r.name) ?? [], ...verifiedByRepo.get(r.name) ?? []], tracker.contract);
12349
12377
  const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
12350
12378
  if (stale.length) {
12351
12379
  anything = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",