@tablation/crew 0.2.0 → 0.3.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/dist/cli.js CHANGED
@@ -2746,7 +2746,7 @@ function qaDigest(i) {
2746
2746
  "## Current queue \u2014 built for you by the poll\n",
2747
2747
  '\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',
2748
2748
  '\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',
2749
- "\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. **MISSING** means the branch is gone and there is\nnothing 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",
2749
+ "\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",
2750
2750
  "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
2751
2751
  table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
2752
2752
  "\n### Awaiting verification, in pick order\n",
@@ -2813,6 +2813,13 @@ function branches(cwd, pattern) {
2813
2813
  return out ? out.split("\n").filter(Boolean) : [];
2814
2814
  }
2815
2815
  function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2816
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2817
+ const found = branches(cwd, c);
2818
+ if (found.length) return found[0];
2819
+ }
2820
+ return null;
2821
+ }
2822
+ function branchCandidates(issueKey, templates, render2) {
2816
2823
  const num = issueKey.replace(/^\D+/, "");
2817
2824
  const unpadded = String(Number.parseInt(num, 10));
2818
2825
  const candidates = [];
@@ -2827,12 +2834,31 @@ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, r
2827
2834
  }
2828
2835
  candidates.push(`issue-${num}`);
2829
2836
  if (num !== unpadded) candidates.push(`issue-${unpadded}`);
2830
- for (const c of [...new Set(candidates)]) {
2831
- const found = branches(cwd, c);
2832
- if (found.length) return found[0];
2837
+ return [...new Set(candidates)];
2838
+ }
2839
+ function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2840
+ const prefix = `refs/remotes/${remote}/`;
2841
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2842
+ const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
2843
+ const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
2844
+ if (hit) return hit.slice(prefix.length);
2833
2845
  }
2834
2846
  return null;
2835
2847
  }
2848
+ function materializeRemoteBranch(cwd, remote, branch) {
2849
+ if (!remoteBranchExists(cwd, remote, branch)) {
2850
+ return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
2851
+ }
2852
+ if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
2853
+ return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
2854
+ }
2855
+ gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
2856
+ return true;
2857
+ }
2858
+ function deleteRemoteBranch(cwd, remote, branch) {
2859
+ if (!remoteBranchExists(cwd, remote, branch)) return true;
2860
+ return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
2861
+ }
2836
2862
  function worktreeForNumber(cwd, num) {
2837
2863
  const re = new RegExp(`(?:^|-)${num}$`);
2838
2864
  return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
@@ -3138,11 +3164,32 @@ function worktreeForTicket(dir, cfg, t, role) {
3138
3164
  const number = t.issue_id.replace(/^\D+/, "");
3139
3165
  return effectiveWorktreeDirName(cfg, dir, branchName, number);
3140
3166
  }
3141
- function existingBranchForTicket(dir, cfg, t, role) {
3167
+ function branchTemplates(cfg, t) {
3142
3168
  const { prefix } = ticketBranchContext(t);
3143
3169
  const name = effectiveBranchTemplate(cfg, prefix);
3144
3170
  const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
3145
- return branchForIssue(dir, t.issue_id, { name, push }, branchRenderer(t, role));
3171
+ return { name, push };
3172
+ }
3173
+ function existingBranchForTicket(dir, cfg, t, role) {
3174
+ return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
3175
+ }
3176
+ function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
3177
+ const local = existingBranchForTicket(dir, cfg, t, role);
3178
+ if (local) return { branch: local, where: "local" };
3179
+ const remote = cfg.branch.remote;
3180
+ if (!remoteConfigured(dir, remote)) return null;
3181
+ if (fetchedDirs && !fetchedDirs.has(dir)) {
3182
+ fetchedDirs.add(dir);
3183
+ fetchRemote(dir, remote);
3184
+ }
3185
+ const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
3186
+ return found ? { branch: found, where: "remote" } : null;
3187
+ }
3188
+ function materializeBranchForTicket(dir, cfg, t, role) {
3189
+ const loc = locateBranchForTicket(dir, cfg, t, role);
3190
+ if (!loc) return null;
3191
+ materializeRemoteBranch(dir, cfg.branch.remote, loc.branch);
3192
+ return loc.branch;
3146
3193
  }
3147
3194
 
3148
3195
  // src/poll.ts
@@ -3312,6 +3359,7 @@ function writeDigest(o, d, role, stateDir) {
3312
3359
  repoCache.set(dir, cfg);
3313
3360
  return cfg;
3314
3361
  };
3362
+ const fetchedDirs = /* @__PURE__ */ new Set();
3315
3363
  const dirFor = (t) => dirForRepo(o.route, t.repo_id);
3316
3364
  const render2 = (t) => branchRenderer(t, role);
3317
3365
  const input = {
@@ -3336,6 +3384,10 @@ function writeDigest(o, d, role, stateDir) {
3336
3384
  existingBranchFor: (t) => {
3337
3385
  const dir = dirFor(t);
3338
3386
  if (!dir) return null;
3387
+ if (role === "qa") {
3388
+ const loc = locateBranchForTicket(dir, repoFor(dir), t, role, fetchedDirs);
3389
+ return loc ? loc.where === "remote" ? `${loc.branch} (on ${repoFor(dir).branch.remote} only)` : loc.branch : null;
3390
+ }
3339
3391
  return existingBranchForTicket(dir, repoFor(dir), t, role);
3340
3392
  },
3341
3393
  tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
@@ -3510,9 +3562,17 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
3510
3562
 
3511
3563
  // src/stranded-verified.ts
3512
3564
  var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
3565
+ var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
3513
3566
  var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
3514
3567
  function planStrandedVerified(candidate, base, remote, comments) {
3515
- return { candidate, base, remote, priorFlags: priorStrandedFlags(candidate.ticket, comments) };
3568
+ const stamps = comments.filter((c) => c.ticket_id === candidate.ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).map((c) => Date.parse(c.created_at)).filter((n) => !Number.isNaN(n));
3569
+ return {
3570
+ candidate,
3571
+ base,
3572
+ remote,
3573
+ priorFlags: priorStrandedFlags(candidate.ticket, comments),
3574
+ lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
3575
+ };
3516
3576
  }
3517
3577
  function flagComment(s) {
3518
3578
  const key = s.candidate.ticket.issue_id;
@@ -3528,12 +3588,16 @@ function escalateComment2(s) {
3528
3588
  return `${STRANDED_MARKER}
3529
3589
  **Still nothing to merge \u2014 stopping rather than repeating.**
3530
3590
 
3531
- \`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
3591
+ \`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
3532
3592
 
3533
3593
  The crew will not close this on its own.`;
3534
3594
  }
3535
- async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun) {
3595
+ async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun, now = Date.now()) {
3536
3596
  const t = { ticket: s.candidate.ticket.issue_id, step: "merge" };
3597
+ if (s.priorFlags > 0 && s.lastFlaggedAt !== void 0 && now - s.lastFlaggedAt < STRANDED_ESCALATE_AFTER_MS) {
3598
+ log.emit("flagged as stranded recently \u2014 not escalating yet", t);
3599
+ return { kind: "waiting" };
3600
+ }
3537
3601
  if (s.priorFlags > 0) {
3538
3602
  if (dryRun) {
3539
3603
  log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 flagged before`, t);
@@ -5313,7 +5377,7 @@ function refreshBase(o) {
5313
5377
  return { ok: true };
5314
5378
  case "fetch-failed":
5315
5379
  o.emit.warn(`${r.detail} \u2014 releasing from what this ship already has`);
5316
- return { ok: true };
5380
+ return { ok: true, stale: true };
5317
5381
  case "would-fast-forward":
5318
5382
  case "fast-forwarded":
5319
5383
  o.emit.emit(r.detail);
@@ -5356,7 +5420,10 @@ async function runRelease(o) {
5356
5420
  const decision = decideRelease(o.cwd, o.tickets, o.contract, {
5357
5421
  tagPattern,
5358
5422
  base: o.repo.branch.base,
5359
- branchFor: (t) => existingBranchForTicket(o.cwd, o.repo, t)
5423
+ // CREW-1364: a branch another ship built and pushed at `fixed` exists
5424
+ // here only on the remote. Materialize it so the squash below has a ref.
5425
+ // A dry run only looks.
5426
+ branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
5360
5427
  });
5361
5428
  if (!fresh.ok) {
5362
5429
  o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
@@ -5405,7 +5472,9 @@ async function runRelease(o) {
5405
5472
  );
5406
5473
  } else {
5407
5474
  o.emit.emit("verified but has no branch and nothing on the base names it \u2014 nothing to merge", { ticket: c.ticket.issue_id });
5408
- unbuildable.push(c);
5475
+ if (fresh.ok && fresh.stale) {
5476
+ o.emit.warn("not flagging as stranded \u2014 the remote could not be fetched, so the branch may exist there", { ticket: c.ticket.issue_id });
5477
+ } else unbuildable.push(c);
5409
5478
  }
5410
5479
  continue;
5411
5480
  }
@@ -7199,6 +7268,7 @@ function schedulerInstalled(plan, exists, crontab) {
7199
7268
  }
7200
7269
 
7201
7270
  // src/worktree-sweep.ts
7271
+ import { existsSync as existsSync14 } from "fs";
7202
7272
  import { basename as basename6 } from "path";
7203
7273
  function planWorktreeSweep(target, tickets, contract) {
7204
7274
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -7218,10 +7288,24 @@ function planWorktreeSweep(target, tickets, contract) {
7218
7288
  }
7219
7289
  return actions;
7220
7290
  }
7221
- async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7291
+ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
7222
7292
  let removed = 0;
7223
7293
  let keptBranches = 0;
7224
7294
  for (const a of actions) {
7295
+ if (!dryRun && existsSync14(a.path)) {
7296
+ let dirty = false;
7297
+ try {
7298
+ dirty = status(a.path).length > 0;
7299
+ } catch {
7300
+ }
7301
+ if (dirty) {
7302
+ log.warn(
7303
+ `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
7304
+ { ticket: a.ticket.issue_id, step: "worktree" }
7305
+ );
7306
+ continue;
7307
+ }
7308
+ }
7225
7309
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
7226
7310
  const pids = pidsInWorktree(a.path, n);
7227
7311
  if (pids.length) {
@@ -7241,7 +7325,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7241
7325
  continue;
7242
7326
  }
7243
7327
  gitOk(cwd, ["worktree", "remove", "--force", a.path]);
7244
- if (!a.keepBranch && a.branch) gitOk(cwd, ["branch", "-D", a.branch]);
7328
+ if (!a.keepBranch && a.branch) {
7329
+ gitOk(cwd, ["branch", "-D", a.branch]);
7330
+ if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
7331
+ log.warn(`could not delete ${remote}/${a.branch} \u2014 leaving it for the next sweep`, { ticket: a.ticket.issue_id, step: "worktree" });
7332
+ }
7333
+ }
7245
7334
  log.emit(
7246
7335
  `removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
7247
7336
  { ticket: a.ticket.issue_id, step: "worktree" }
@@ -7250,14 +7339,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7250
7339
  }
7251
7340
  return { removed, keptBranches };
7252
7341
  }
7342
+ function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
7343
+ if (!remoteConfigured(cwd, remote)) return [];
7344
+ const stems = /* @__PURE__ */ new Set();
7345
+ for (const t of tickets) {
7346
+ if (t.status !== contract.statuses.deployed) continue;
7347
+ const num = t.issue_id.replace(/^\D+/, "");
7348
+ const unpadded = String(Number.parseInt(num, 10));
7349
+ const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
7350
+ for (const p of prefixes) {
7351
+ stems.add(`${p}-${num}`);
7352
+ stems.add(`${p}-${unpadded}`);
7353
+ }
7354
+ }
7355
+ if (stems.size === 0) return [];
7356
+ const prefix = `refs/remotes/${remote}/`;
7357
+ const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
7358
+ const out = [];
7359
+ for (const ref of refs) {
7360
+ const name = ref.slice(prefix.length);
7361
+ if (name === "HEAD" || name === base) continue;
7362
+ const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
7363
+ if (m && stems.has(m[1])) out.push(name);
7364
+ }
7365
+ return out;
7366
+ }
7367
+ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
7368
+ let deleted = 0;
7369
+ for (const b of branches2) {
7370
+ if (dryRun) {
7371
+ log.emit(`would delete ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
7372
+ continue;
7373
+ }
7374
+ if (deleteRemoteBranch(cwd, remote, b)) {
7375
+ deleted++;
7376
+ log.emit(`deleted ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
7377
+ } else log.warn(`could not delete ${remote}/${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
7378
+ }
7379
+ return deleted;
7380
+ }
7253
7381
 
7254
7382
  // src/stream-sweep.ts
7255
- import { existsSync as existsSync14, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
7383
+ import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
7256
7384
  import { join as join13 } from "path";
7257
7385
  var DAY_MS = 24 * 60 * 60 * 1e3;
7258
7386
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
7259
7387
  const dir = join13(stateDir, "streams");
7260
- if (!existsSync14(dir)) return [];
7388
+ if (!existsSync15(dir)) return [];
7261
7389
  const cutoff = now - retentionDays * DAY_MS;
7262
7390
  return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
7263
7391
  try {
@@ -7286,13 +7414,13 @@ function applyStreamSweep(paths, dryRun, log) {
7286
7414
  }
7287
7415
 
7288
7416
  // src/cli.ts
7289
- import { readFileSync as readFileSync12, existsSync as existsSync18, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
7417
+ import { readFileSync as readFileSync12, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
7290
7418
  import { createInterface } from "readline/promises";
7291
7419
  import { dirname as dirOf, resolve as resolvePath } from "path";
7292
7420
 
7293
7421
  // src/ssh-keys.ts
7294
7422
  import { execFileSync as execFileSync3 } from "child_process";
7295
- import { existsSync as existsSync15, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
7423
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
7296
7424
  import { dirname as dirname8, join as join14 } from "path";
7297
7425
  var SshKeyError = class extends Error {
7298
7426
  };
@@ -7302,7 +7430,7 @@ function shipSshKeyPathFor(stateDir) {
7302
7430
  function ensureShipSshKeypair(stateDir) {
7303
7431
  const privateKeyPath = shipSshKeyPathFor(stateDir);
7304
7432
  const publicKeyPath = `${privateKeyPath}.pub`;
7305
- if (!existsSync15(privateKeyPath)) {
7433
+ if (!existsSync16(privateKeyPath)) {
7306
7434
  mkdirSync7(dirname8(privateKeyPath), { recursive: true });
7307
7435
  try {
7308
7436
  execFileSync3("ssh-keygen", [
@@ -7323,7 +7451,7 @@ function ensureShipSshKeypair(stateDir) {
7323
7451
  }
7324
7452
  chmodSync(privateKeyPath, 384);
7325
7453
  }
7326
- if (!existsSync15(publicKeyPath)) {
7454
+ if (!existsSync16(publicKeyPath)) {
7327
7455
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
7328
7456
  }
7329
7457
  const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
@@ -7516,7 +7644,7 @@ function syncAllPassengerCheckouts(cfg) {
7516
7644
 
7517
7645
  // src/tunnel.ts
7518
7646
  import { spawn as spawn3 } from "child_process";
7519
- import { existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
7647
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
7520
7648
  import { randomInt } from "crypto";
7521
7649
  import { join as join15 } from "path";
7522
7650
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -7589,7 +7717,7 @@ function tunnelStatePath(stateDir, workspaceId) {
7589
7717
  }
7590
7718
  function readPersistedTunnel(stateDir, workspaceId) {
7591
7719
  const path = tunnelStatePath(stateDir, workspaceId);
7592
- if (!existsSync16(path)) return void 0;
7720
+ if (!existsSync17(path)) return void 0;
7593
7721
  try {
7594
7722
  return JSON.parse(readFileSync10(path, "utf8"));
7595
7723
  } catch {
@@ -7697,7 +7825,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
7697
7825
  }
7698
7826
  function readAllPersistedWorkspaceIds(stateDir) {
7699
7827
  const dir = join15(stateDir, "passengers");
7700
- if (!existsSync16(dir)) return [];
7828
+ if (!existsSync17(dir)) return [];
7701
7829
  return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
7702
7830
  }
7703
7831
 
@@ -7818,7 +7946,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
7818
7946
  import { createServer as createHttpServer } from "http";
7819
7947
  import { spawn as spawn4 } from "child_process";
7820
7948
  import { randomBytes } from "crypto";
7821
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
7949
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
7822
7950
  import { join as join16 } from "path";
7823
7951
 
7824
7952
  // src/secret-compare.ts
@@ -7912,7 +8040,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
7912
8040
  }
7913
8041
  function readPersistedSyncDaemon(stateDir, workspaceId) {
7914
8042
  const path = syncDaemonStatePath(stateDir, workspaceId);
7915
- if (!existsSync17(path)) return void 0;
8043
+ if (!existsSync18(path)) return void 0;
7916
8044
  try {
7917
8045
  return JSON.parse(readFileSync11(path, "utf8"));
7918
8046
  } catch {
@@ -7943,7 +8071,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
7943
8071
  }
7944
8072
  function readAllPersistedSyncWorkspaceIds(stateDir) {
7945
8073
  const dir = join16(stateDir, "passengers");
7946
- if (!existsSync17(dir)) return [];
8074
+ if (!existsSync18(dir)) return [];
7947
8075
  const suffix = "-sync.json";
7948
8076
  return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
7949
8077
  }
@@ -8655,7 +8783,7 @@ if (command === "passenger-sync-daemon") {
8655
8783
  if (host === "linux" && hasSystemd) {
8656
8784
  const label = labelFor(CREW_HOME, "run");
8657
8785
  const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
8658
- if (existsSync18(staleTimer)) {
8786
+ if (existsSync19(staleTimer)) {
8659
8787
  process.stdout.write(
8660
8788
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
8661
8789
  `
@@ -8680,7 +8808,7 @@ if (command === "passenger-sync-daemon") {
8680
8808
  const tracker = new Tracker(c, cfg.ship);
8681
8809
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
8682
8810
  for (const r of await resolvedRepos(c)) {
8683
- if (!existsSync18(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
8811
+ if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
8684
8812
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
8685
8813
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
8686
8814
  }
@@ -8698,7 +8826,7 @@ if (command === "passenger-sync-daemon") {
8698
8826
  }
8699
8827
  async function doctorFix(o) {
8700
8828
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
8701
- const unitExists = (p) => existsSync18(p);
8829
+ const unitExists = (p) => existsSync19(p);
8702
8830
  const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
8703
8831
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
8704
8832
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -8918,9 +9046,11 @@ ${changed2.map((c) => ` - ${c}
8918
9046
  const actions = planWorktreeSweep(target, scoped, tracker.contract);
8919
9047
  if (actions.length) {
8920
9048
  remit.enter("worktree");
8921
- const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
9049
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
8922
9050
  remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
8923
9051
  }
9052
+ const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
9053
+ if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
8924
9054
  } catch (e) {
8925
9055
  remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
8926
9056
  }
@@ -9574,7 +9704,7 @@ ${changed2.map((c) => ` - ${c}
9574
9704
  `);
9575
9705
  if (!dryRun) {
9576
9706
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9577
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9707
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9578
9708
  raw.agentPersonas = personaResult.agentPersonas;
9579
9709
  raw.agentSkills = skillResult.agentSkills;
9580
9710
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
@@ -9623,7 +9753,7 @@ ${changed2.map((c) => ` - ${c}
9623
9753
  `);
9624
9754
  if (!dryRun) {
9625
9755
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9626
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9756
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9627
9757
  raw.agentSkills = result.agentSkills;
9628
9758
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
9629
9759
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -9646,7 +9776,7 @@ ${changed2.map((c) => ` - ${c}
9646
9776
  if (sub === "list" && !routeName) usage2();
9647
9777
  if (sub === "add") {
9648
9778
  try {
9649
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync18(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
9779
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
9650
9780
  routeName = a.route;
9651
9781
  addPath = a.path;
9652
9782
  addName = a.name;
@@ -9661,7 +9791,7 @@ ${changed2.map((c) => ` - ${c}
9661
9791
  }
9662
9792
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
9663
9793
  let resolvedRemotes;
9664
- if (existsSync18(resolvedFile)) {
9794
+ if (existsSync19(resolvedFile)) {
9665
9795
  try {
9666
9796
  resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9667
9797
  } catch {
@@ -9678,14 +9808,14 @@ ${changed2.map((c) => ` - ${c}
9678
9808
  for (const r of repos) {
9679
9809
  const origin2 = originOf(r.dir);
9680
9810
  const row = matchRepoName(origin2, resolvedRemotes);
9681
- const note = !existsSync18(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
9811
+ const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
9682
9812
  process.stdout.write(`${r.name} ${r.dir} ${note}
9683
9813
  `);
9684
9814
  }
9685
9815
  break;
9686
9816
  }
9687
9817
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
9688
- const top = existsSync18(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9818
+ const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9689
9819
  if (!top) throw new ReposError(`${target} is not a git checkout`);
9690
9820
  const origin = originOf(top);
9691
9821
  const plan = planRepoAdd({
@@ -9733,7 +9863,7 @@ ${changed2.map((c) => ` - ${c}
9733
9863
  const created = await rowTracker.createRepoRow(rowPayload);
9734
9864
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
9735
9865
  `);
9736
- if (existsSync18(resolvedFile)) {
9866
+ if (existsSync19(resolvedFile)) {
9737
9867
  const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9738
9868
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
9739
9869
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -9966,7 +10096,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
9966
10096
  operator = await pickOperator(found.holds);
9967
10097
  }
9968
10098
  let previous;
9969
- const hadPreviousFile = existsSync18(resolvedPath);
10099
+ const hadPreviousFile = existsSync19(resolvedPath);
9970
10100
  if (hadPreviousFile) {
9971
10101
  try {
9972
10102
  previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
@@ -10222,9 +10352,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10222
10352
  const { byRepo } = ticketsByRepo(route, terminal);
10223
10353
  for (const r of await resolvedRepos(route)) {
10224
10354
  const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
10355
+ const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
10356
+ if (stale.length) {
10357
+ anything = true;
10358
+ applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
10359
+ }
10225
10360
  if (!actions.length) continue;
10226
10361
  anything = true;
10227
- const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
10362
+ const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
10228
10363
  emit.emit(
10229
10364
  `swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
10230
10365
  { step: "worktree" }
@@ -10280,8 +10415,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10280
10415
  break;
10281
10416
  }
10282
10417
  gitOk(target.dir, ["worktree", "remove", "--force", wt]);
10283
- if (branch) gitOk(target.dir, ["branch", "-D", branch]);
10284
- else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
10418
+ if (branch) {
10419
+ gitOk(target.dir, ["branch", "-D", branch]);
10420
+ const { remote, base } = target.config.branch;
10421
+ if (remoteConfigured(target.dir, remote) && remoteBranchExists(target.dir, remote, branch)) {
10422
+ if (findKeyInRange(target.dir, `ISSUE-${n}`, `${remote}/${base}`)) {
10423
+ emit.emit(deleteRemoteBranch(target.dir, remote, branch) ? `deleted ${remote}/${branch}` : `could not delete ${remote}/${branch}`);
10424
+ } else {
10425
+ emit.warn(`${remote}/${branch} kept \u2014 nothing on ${remote}/${base} names ISSUE-${n} yet, so it may be the only copy`);
10426
+ }
10427
+ }
10428
+ } else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
10285
10429
  emit.emit(`dropped ISSUE-${n} from ${target.name}`);
10286
10430
  break;
10287
10431
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -191,6 +191,12 @@ since your last comment: an answer to a question, new direction, or a
191
191
  comment is what clears you to act again, not the flag itself. Clearing
192
192
  `needs_planning`/`needs_review` is always the operator's move, never
193
193
  yours, whatever you find when you resume.
194
+ - **Before resuming in an existing worktree, run `crew sync`** (it fetches and
195
+ fast-forwards every worktree branch that tracks a remote, never merging or
196
+ rebasing) — another ship may have pushed to the branch since you left it.
197
+ If it reports your branch as diverged (local commits not on the remote AND
198
+ the remote has moved), stop: comment on the ticket with what it printed and
199
+ set `needs_info`; do not merge or rebase it yourself.
194
200
  - A ticket QA has bounced back to you comes in as `in_progress`,
195
201
  reassigned to your row, with a comment saying what still fails. Treat
196
202
  that exactly like new direction from a hold: read the comment, fix what
@@ -520,6 +526,15 @@ if `report_type` is either of those.
520
526
  part).
521
527
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
522
528
  and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
529
+ **Push the branch first, in the same step:**
530
+ `git push --set-upstream <remote> <branch>` from the worktree (the remote is
531
+ `origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
532
+ configured has nothing to push to, so say so in your comment and skip it).
533
+ Another ship may be the one that verifies and releases this ticket, and a
534
+ branch that never left this machine looks MISSING to it. A bounce back to
535
+ `in_progress` and the next `fixed` push again — a plain fast-forward, never
536
+ `--force`; if the push is rejected, someone else moved the branch, so stop
537
+ and say so on the ticket instead of forcing it.
523
538
  Leave the worktree and branch exactly where they are, unmerged: QA boots
524
539
  *your worktree* on its derived ports to test the fix, so removing it
525
540
  would leave QA nothing to test. Your last progress comment is what QA
@@ -50,6 +50,12 @@ human review, so "probably fine" is not a pass.
50
50
  Does the change actually do what the comment claims? Does it handle the
51
51
  empty/error/permission path, or only the happy one? Does it touch
52
52
  anything the ticket never mentioned?
53
+ **A branch on the remote is testable.** The builder may be on another ship:
54
+ the digest's `branch` column then reads `<name> (on origin only)` and no
55
+ worktree exists here yet. Cut one from the remote branch —
56
+ `git worktree add <the digest's worktree> <name>` from the ticket's repo
57
+ checkout (git creates the local tracking branch) — and carry on. Only
58
+ **MISSING** (gone locally and on the remote) means there is nothing to test.
53
59
  5. **Run it.** The builder left its worktree in place for you: `cd` into it,
54
60
  `nvm use` if the repository pins a version, and `eval` the **`ports`** hook
55
61
  for this worktree's own ports (anything already listening on them is a dead
@@ -172,6 +172,12 @@ since your last comment: an answer to a question, new direction, or a
172
172
  to leave such tickets alone, which — combined with the poll not waking
173
173
  for them — left ISSUE-049 parked for hours mid-implementation with no
174
174
  question outstanding, restartable only by someone commenting on it.
175
+ - **Before resuming in an existing worktree, run `crew sync`** (it fetches and
176
+ fast-forwards every worktree branch that tracks a remote, never merging or
177
+ rebasing) — another ship may have pushed to the branch since you left it.
178
+ If it reports your branch as diverged (local commits not on the remote AND
179
+ the remote has moved), stop: comment on the ticket with what it printed and
180
+ set `needs_info`; do not merge or rebase it yourself.
175
181
  - A ticket QA has bounced back to you comes in as `in_progress`,
176
182
  reassigned to your row, with a comment saying what still fails. Treat
177
183
  that exactly like new direction from a hold: read the comment, fix what
@@ -501,6 +507,15 @@ if `report_type` is either of those.
501
507
  part).
502
508
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
503
509
  and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
510
+ **Push the branch first, in the same step:**
511
+ `git push --set-upstream <remote> <branch>` from the worktree (the remote is
512
+ `origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
513
+ configured has nothing to push to, so say so in your comment and skip it).
514
+ Another ship may be the one that verifies and releases this ticket, and a
515
+ branch that never left this machine looks MISSING to it. A bounce back to
516
+ `in_progress` and the next `fixed` push again — a plain fast-forward, never
517
+ `--force`; if the push is rejected, someone else moved the branch, so stop
518
+ and say so on the ticket instead of forcing it.
504
519
  Leave the worktree and branch exactly where they are, unmerged: QA boots
505
520
  *your worktree* on its derived ports to test the fix, so removing it
506
521
  would leave QA nothing to test. Your last progress comment is what QA