@tablation/crew 0.2.0 → 0.4.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
@@ -1328,6 +1328,8 @@ var State = class _State {
1328
1328
  const suffix = _State.safe(route);
1329
1329
  const blocked = `.release-blocked-${suffix}`;
1330
1330
  const failed = `.deploy-failed-sha-${suffix}`;
1331
+ const gateFailed = `.test-gate-failed-${suffix}`;
1332
+ const gateReported = `.test-gate-reported-${suffix}`;
1331
1333
  const read = (f) => {
1332
1334
  try {
1333
1335
  return readFileSync3(this.path(f), "utf8").trim() || null;
@@ -1361,6 +1363,27 @@ var State = class _State {
1361
1363
  },
1362
1364
  clearDeployFailed: () => {
1363
1365
  rmSync(this.path(failed), { force: true });
1366
+ },
1367
+ /**
1368
+ * Consecutive test-gate failures on the same head (CREW-1368). A failure
1369
+ * on a new head restarts the count. Held on disk because one process is
1370
+ * one cycle, and "failed three cycles running" only exists across them.
1371
+ */
1372
+ noteTestGateFailed: (sha) => {
1373
+ const [prev, n] = (read(gateFailed) ?? "").split(" ");
1374
+ const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
1375
+ writeFileSync(this.path(gateFailed), `${sha} ${next}
1376
+ `);
1377
+ return next;
1378
+ },
1379
+ clearTestGateFailed: () => {
1380
+ rmSync(this.path(gateFailed), { force: true });
1381
+ },
1382
+ /** Whether the board was already told about this head's red gate. */
1383
+ testGateReported: (sha) => read(gateReported) === sha,
1384
+ noteTestGateReported: (sha) => {
1385
+ writeFileSync(this.path(gateReported), `${sha}
1386
+ `);
1364
1387
  }
1365
1388
  };
1366
1389
  }
@@ -2746,7 +2769,7 @@ function qaDigest(i) {
2746
2769
  "## Current queue \u2014 built for you by the poll\n",
2747
2770
  '\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
2771
  '\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",
2772
+ "\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
2773
  "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
2751
2774
  table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
2752
2775
  "\n### Awaiting verification, in pick order\n",
@@ -2813,6 +2836,13 @@ function branches(cwd, pattern) {
2813
2836
  return out ? out.split("\n").filter(Boolean) : [];
2814
2837
  }
2815
2838
  function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2839
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2840
+ const found = branches(cwd, c);
2841
+ if (found.length) return found[0];
2842
+ }
2843
+ return null;
2844
+ }
2845
+ function branchCandidates(issueKey, templates, render2) {
2816
2846
  const num = issueKey.replace(/^\D+/, "");
2817
2847
  const unpadded = String(Number.parseInt(num, 10));
2818
2848
  const candidates = [];
@@ -2827,12 +2857,31 @@ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, r
2827
2857
  }
2828
2858
  candidates.push(`issue-${num}`);
2829
2859
  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];
2860
+ return [...new Set(candidates)];
2861
+ }
2862
+ function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2863
+ const prefix = `refs/remotes/${remote}/`;
2864
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2865
+ const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
2866
+ const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
2867
+ if (hit) return hit.slice(prefix.length);
2833
2868
  }
2834
2869
  return null;
2835
2870
  }
2871
+ function materializeRemoteBranch(cwd, remote, branch) {
2872
+ if (!remoteBranchExists(cwd, remote, branch)) {
2873
+ return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
2874
+ }
2875
+ if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
2876
+ return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
2877
+ }
2878
+ gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
2879
+ return true;
2880
+ }
2881
+ function deleteRemoteBranch(cwd, remote, branch) {
2882
+ if (!remoteBranchExists(cwd, remote, branch)) return true;
2883
+ return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
2884
+ }
2836
2885
  function worktreeForNumber(cwd, num) {
2837
2886
  const re = new RegExp(`(?:^|-)${num}$`);
2838
2887
  return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
@@ -2843,6 +2892,10 @@ var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--co
2843
2892
  function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
2844
2893
  return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
2845
2894
  }
2895
+ function firstReleaseTagContaining(cwd, sha, pattern = "v*") {
2896
+ const out = gitOk(cwd, ["tag", "--contains", sha, "--list", pattern, "--sort=creatordate"]);
2897
+ return out?.split("\n").map((l) => l.trim()).find(Boolean) ?? null;
2898
+ }
2846
2899
  var tagCommit = (cwd, tag) => resolve2(cwd, tag);
2847
2900
  function createReleaseTag(cwd, tag, sha, message) {
2848
2901
  git(cwd, ["tag", "-a", tag, sha, "-m", message]);
@@ -3138,11 +3191,32 @@ function worktreeForTicket(dir, cfg, t, role) {
3138
3191
  const number = t.issue_id.replace(/^\D+/, "");
3139
3192
  return effectiveWorktreeDirName(cfg, dir, branchName, number);
3140
3193
  }
3141
- function existingBranchForTicket(dir, cfg, t, role) {
3194
+ function branchTemplates(cfg, t) {
3142
3195
  const { prefix } = ticketBranchContext(t);
3143
3196
  const name = effectiveBranchTemplate(cfg, prefix);
3144
3197
  const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
3145
- return branchForIssue(dir, t.issue_id, { name, push }, branchRenderer(t, role));
3198
+ return { name, push };
3199
+ }
3200
+ function existingBranchForTicket(dir, cfg, t, role) {
3201
+ return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
3202
+ }
3203
+ function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
3204
+ const local = existingBranchForTicket(dir, cfg, t, role);
3205
+ if (local) return { branch: local, where: "local" };
3206
+ const remote = cfg.branch.remote;
3207
+ if (!remoteConfigured(dir, remote)) return null;
3208
+ if (fetchedDirs && !fetchedDirs.has(dir)) {
3209
+ fetchedDirs.add(dir);
3210
+ fetchRemote(dir, remote);
3211
+ }
3212
+ const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
3213
+ return found ? { branch: found, where: "remote" } : null;
3214
+ }
3215
+ function materializeBranchForTicket(dir, cfg, t, role) {
3216
+ const loc = locateBranchForTicket(dir, cfg, t, role);
3217
+ if (!loc) return null;
3218
+ materializeRemoteBranch(dir, cfg.branch.remote, loc.branch);
3219
+ return loc.branch;
3146
3220
  }
3147
3221
 
3148
3222
  // src/poll.ts
@@ -3312,6 +3386,7 @@ function writeDigest(o, d, role, stateDir) {
3312
3386
  repoCache.set(dir, cfg);
3313
3387
  return cfg;
3314
3388
  };
3389
+ const fetchedDirs = /* @__PURE__ */ new Set();
3315
3390
  const dirFor = (t) => dirForRepo(o.route, t.repo_id);
3316
3391
  const render2 = (t) => branchRenderer(t, role);
3317
3392
  const input = {
@@ -3336,6 +3411,10 @@ function writeDigest(o, d, role, stateDir) {
3336
3411
  existingBranchFor: (t) => {
3337
3412
  const dir = dirFor(t);
3338
3413
  if (!dir) return null;
3414
+ if (role === "qa") {
3415
+ const loc = locateBranchForTicket(dir, repoFor(dir), t, role, fetchedDirs);
3416
+ return loc ? loc.where === "remote" ? `${loc.branch} (on ${repoFor(dir).branch.remote} only)` : loc.branch : null;
3417
+ }
3339
3418
  return existingBranchForTicket(dir, repoFor(dir), t, role);
3340
3419
  },
3341
3420
  tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
@@ -3510,16 +3589,24 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
3510
3589
 
3511
3590
  // src/stranded-verified.ts
3512
3591
  var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
3592
+ var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
3513
3593
  var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
3514
3594
  function planStrandedVerified(candidate, base, remote, comments) {
3515
- return { candidate, base, remote, priorFlags: priorStrandedFlags(candidate.ticket, comments) };
3595
+ 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));
3596
+ return {
3597
+ candidate,
3598
+ base,
3599
+ remote,
3600
+ priorFlags: priorStrandedFlags(candidate.ticket, comments),
3601
+ lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
3602
+ };
3516
3603
  }
3517
3604
  function flagComment(s) {
3518
3605
  const key = s.candidate.ticket.issue_id;
3519
3606
  return `${STRANDED_MARKER}
3520
3607
  **Verified, but nothing to merge \u2014 flagging before this repeats forever.**
3521
3608
 
3522
- \`${key}\` is \`verified\` with no branch on this ship, and its key names no commit on \`${s.base}\` (searched \`${s.remote}/${s.base}\`'s history for a bounded \`${key}\` in a commit subject or body). One of three things is true: it landed under a commit that never named the key, the branch was deleted before it merged, or it was verified in error \u2014 the crew cannot tell these apart and will not guess.
3609
+ \`${key}\` is \`verified\` with no branch on this ship, and its key names no commit anywhere in this ship's \`${s.base}\` history (searched all of it, local commits included, for a bounded \`${key}\` in a commit subject or body). One of three things is true: it landed under a commit that never named the key, the branch was deleted before it merged, or it was verified in error \u2014 the crew cannot tell these apart and will not guess.
3523
3610
 
3524
3611
  If it is still unplaceable next cycle, this ticket is escalated to a person. If the work already landed, closing this ticket by hand now avoids that.`;
3525
3612
  }
@@ -3528,12 +3615,16 @@ function escalateComment2(s) {
3528
3615
  return `${STRANDED_MARKER}
3529
3616
  **Still nothing to merge \u2014 stopping rather than repeating.**
3530
3617
 
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.
3618
+ \`${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 in this ship's \`${s.base}\` history naming its key (searched all of it, local commits included). 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
3619
 
3533
3620
  The crew will not close this on its own.`;
3534
3621
  }
3535
- async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun) {
3622
+ async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun, now = Date.now()) {
3536
3623
  const t = { ticket: s.candidate.ticket.issue_id, step: "merge" };
3624
+ if (s.priorFlags > 0 && s.lastFlaggedAt !== void 0 && now - s.lastFlaggedAt < STRANDED_ESCALATE_AFTER_MS) {
3625
+ log.emit("flagged as stranded recently \u2014 not escalating yet", t);
3626
+ return { kind: "waiting" };
3627
+ }
3537
3628
  if (s.priorFlags > 0) {
3538
3629
  if (dryRun) {
3539
3630
  log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 flagged before`, t);
@@ -3568,6 +3659,33 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
3568
3659
  }
3569
3660
  }
3570
3661
 
3662
+ // src/gate-red.ts
3663
+ var GATE_RED_MARKER = "<!-- crew:test-gate-red";
3664
+ var GATE_RED_AFTER = 3;
3665
+ var lastLines = (output, n) => output.replace(/\u001b\[[0-9;]*m/g, "").trimEnd().split("\n").slice(-n).join("\n");
3666
+ function gateRedTickets(merged, merges) {
3667
+ const seen = /* @__PURE__ */ new Set();
3668
+ const out = [];
3669
+ for (const c of [...merged, ...merges.filter((m) => m.skipReason === "already-merged")]) {
3670
+ if (seen.has(c.ticket.id)) continue;
3671
+ seen.add(c.ticket.id);
3672
+ out.push(c.ticket);
3673
+ }
3674
+ return out;
3675
+ }
3676
+ function gateRedComment(g) {
3677
+ return `${GATE_RED_MARKER} ${g.sha.slice(0, 8)} -->
3678
+ **This ticket is merged on the base, but the release test gate is red \u2014 nothing has shipped.**
3679
+
3680
+ The \`test\` hook (\`${g.hook}\`) has failed ${g.count} cycles running on the same head (\`${g.sha.slice(0, 8)}\`). The ticket's commit is on the base and will ship once the gate passes; it is not stranded, and re-setting it to \`verified\` will not help. Last lines of the hook's output:
3681
+
3682
+ \`\`\`
3683
+ ${g.tail}
3684
+ \`\`\`
3685
+ `;
3686
+ }
3687
+ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
3688
+
3571
3689
  // src/agent.ts
3572
3690
  import { spawn } from "child_process";
3573
3691
  import { readFileSync as readFileSync5, existsSync as existsSync6, statSync as statSync2, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, createWriteStream } from "fs";
@@ -4824,7 +4942,32 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
4824
4942
  return verified.map((ticket) => {
4825
4943
  const branch = branchFor(ticket);
4826
4944
  if (!branch) {
4827
- const onMain = lastReleased ? findKeyInRange(cwd, referenceKeys(ticket), `${lastReleased}..HEAD`) !== null : false;
4945
+ const keys = referenceKeys(ticket);
4946
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..HEAD`) : null;
4947
+ if (unreleased) {
4948
+ return {
4949
+ ticket,
4950
+ branch: null,
4951
+ entries: [],
4952
+ usedFallback: false,
4953
+ bump: "patch",
4954
+ majorRequested: false,
4955
+ skipReason: "already-merged"
4956
+ };
4957
+ }
4958
+ const anywhere = findKeyInRange(cwd, keys, "HEAD");
4959
+ if (anywhere) {
4960
+ return {
4961
+ ticket,
4962
+ branch: null,
4963
+ entries: [],
4964
+ usedFallback: false,
4965
+ bump: "patch",
4966
+ majorRequested: false,
4967
+ skipReason: lastReleased ? "already-released" : "already-merged",
4968
+ mergedSha: anywhere
4969
+ };
4970
+ }
4828
4971
  return {
4829
4972
  ticket,
4830
4973
  branch: null,
@@ -4832,7 +4975,7 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
4832
4975
  usedFallback: false,
4833
4976
  bump: "patch",
4834
4977
  majorRequested: false,
4835
- skipReason: onMain ? "already-merged" : "never-built"
4978
+ skipReason: "never-built"
4836
4979
  };
4837
4980
  }
4838
4981
  const squash = findSquashOfBranch(
@@ -5313,7 +5456,7 @@ function refreshBase(o) {
5313
5456
  return { ok: true };
5314
5457
  case "fetch-failed":
5315
5458
  o.emit.warn(`${r.detail} \u2014 releasing from what this ship already has`);
5316
- return { ok: true };
5459
+ return { ok: true, stale: true };
5317
5460
  case "would-fast-forward":
5318
5461
  case "fast-forwarded":
5319
5462
  o.emit.emit(r.detail);
@@ -5356,7 +5499,10 @@ async function runRelease(o) {
5356
5499
  const decision = decideRelease(o.cwd, o.tickets, o.contract, {
5357
5500
  tagPattern,
5358
5501
  base: o.repo.branch.base,
5359
- branchFor: (t) => existingBranchForTicket(o.cwd, o.repo, t)
5502
+ // CREW-1364: a branch another ship built and pushed at `fixed` exists
5503
+ // here only on the remote. Materialize it so the squash below has a ref.
5504
+ // A dry run only looks.
5505
+ branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
5360
5506
  });
5361
5507
  if (!fresh.ok) {
5362
5508
  o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
@@ -5398,14 +5544,21 @@ async function runRelease(o) {
5398
5544
  let applied = 0;
5399
5545
  for (const c of decision.merges) {
5400
5546
  if (!c.branch || c.mergedSha) {
5401
- if (c.skipReason === "already-merged") {
5547
+ if (c.skipReason === "already-released") {
5548
+ o.emit.emit(
5549
+ `verified and already released (on the base as ${c.mergedSha?.slice(0, 7)}), waiting to be stamped`,
5550
+ { ticket: c.ticket.issue_id }
5551
+ );
5552
+ } else if (c.skipReason === "already-merged") {
5402
5553
  o.emit.emit(
5403
5554
  `verified and already merged${c.mergedSha ? ` (on the base as ${c.mergedSha.slice(0, 7)})` : ""}, waiting on a successful release`,
5404
5555
  { ticket: c.ticket.issue_id }
5405
5556
  );
5406
5557
  } else {
5407
5558
  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);
5559
+ if (fresh.ok && fresh.stale) {
5560
+ 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 });
5561
+ } else unbuildable.push(c);
5409
5562
  }
5410
5563
  continue;
5411
5564
  }
@@ -5501,8 +5654,12 @@ async function runRelease(o) {
5501
5654
  }
5502
5655
  if (r && r.code !== 0) {
5503
5656
  o.emit.error(`test gate FAILED \u2014 not deploying; the target stays on the previous release`);
5504
- return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision };
5657
+ const sha = headSha(o.cwd);
5658
+ const count = o.state?.noteTestGateFailed?.(sha) ?? 1;
5659
+ const gateRed = count >= GATE_RED_AFTER && !o.state?.testGateReported?.(sha) ? { sha, count, hook: hookLabel(o.repo, "test"), tail: lastLines(r.output, 15) } : void 0;
5660
+ return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed };
5505
5661
  }
5662
+ o.state?.clearTestGateFailed?.();
5506
5663
  }
5507
5664
  }
5508
5665
  const size = merged.some((m) => m.bump === "minor") ? "minor" : "patch";
@@ -7199,6 +7356,7 @@ function schedulerInstalled(plan, exists, crontab) {
7199
7356
  }
7200
7357
 
7201
7358
  // src/worktree-sweep.ts
7359
+ import { existsSync as existsSync14 } from "fs";
7202
7360
  import { basename as basename6 } from "path";
7203
7361
  function planWorktreeSweep(target, tickets, contract) {
7204
7362
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -7218,10 +7376,24 @@ function planWorktreeSweep(target, tickets, contract) {
7218
7376
  }
7219
7377
  return actions;
7220
7378
  }
7221
- async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7379
+ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
7222
7380
  let removed = 0;
7223
7381
  let keptBranches = 0;
7224
7382
  for (const a of actions) {
7383
+ if (!dryRun && existsSync14(a.path)) {
7384
+ let dirty = false;
7385
+ try {
7386
+ dirty = status(a.path).length > 0;
7387
+ } catch {
7388
+ }
7389
+ if (dirty) {
7390
+ log.warn(
7391
+ `not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
7392
+ { ticket: a.ticket.issue_id, step: "worktree" }
7393
+ );
7394
+ continue;
7395
+ }
7396
+ }
7225
7397
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
7226
7398
  const pids = pidsInWorktree(a.path, n);
7227
7399
  if (pids.length) {
@@ -7241,7 +7413,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7241
7413
  continue;
7242
7414
  }
7243
7415
  gitOk(cwd, ["worktree", "remove", "--force", a.path]);
7244
- if (!a.keepBranch && a.branch) gitOk(cwd, ["branch", "-D", a.branch]);
7416
+ if (!a.keepBranch && a.branch) {
7417
+ gitOk(cwd, ["branch", "-D", a.branch]);
7418
+ if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
7419
+ log.warn(`could not delete ${remote}/${a.branch} \u2014 leaving it for the next sweep`, { ticket: a.ticket.issue_id, step: "worktree" });
7420
+ }
7421
+ }
7245
7422
  log.emit(
7246
7423
  `removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
7247
7424
  { ticket: a.ticket.issue_id, step: "worktree" }
@@ -7250,14 +7427,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7250
7427
  }
7251
7428
  return { removed, keptBranches };
7252
7429
  }
7430
+ function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
7431
+ if (!remoteConfigured(cwd, remote)) return [];
7432
+ const stems = /* @__PURE__ */ new Set();
7433
+ for (const t of tickets) {
7434
+ if (t.status !== contract.statuses.deployed) continue;
7435
+ const num = t.issue_id.replace(/^\D+/, "");
7436
+ const unpadded = String(Number.parseInt(num, 10));
7437
+ const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
7438
+ for (const p of prefixes) {
7439
+ stems.add(`${p}-${num}`);
7440
+ stems.add(`${p}-${unpadded}`);
7441
+ }
7442
+ }
7443
+ if (stems.size === 0) return [];
7444
+ const prefix = `refs/remotes/${remote}/`;
7445
+ const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
7446
+ const out = [];
7447
+ for (const ref of refs) {
7448
+ const name = ref.slice(prefix.length);
7449
+ if (name === "HEAD" || name === base) continue;
7450
+ const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
7451
+ if (m && stems.has(m[1])) out.push(name);
7452
+ }
7453
+ return out;
7454
+ }
7455
+ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
7456
+ let deleted = 0;
7457
+ for (const b of branches2) {
7458
+ if (dryRun) {
7459
+ log.emit(`would delete ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
7460
+ continue;
7461
+ }
7462
+ if (deleteRemoteBranch(cwd, remote, b)) {
7463
+ deleted++;
7464
+ log.emit(`deleted ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
7465
+ } else log.warn(`could not delete ${remote}/${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
7466
+ }
7467
+ return deleted;
7468
+ }
7253
7469
 
7254
7470
  // src/stream-sweep.ts
7255
- import { existsSync as existsSync14, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
7471
+ import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
7256
7472
  import { join as join13 } from "path";
7257
7473
  var DAY_MS = 24 * 60 * 60 * 1e3;
7258
7474
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
7259
7475
  const dir = join13(stateDir, "streams");
7260
- if (!existsSync14(dir)) return [];
7476
+ if (!existsSync15(dir)) return [];
7261
7477
  const cutoff = now - retentionDays * DAY_MS;
7262
7478
  return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
7263
7479
  try {
@@ -7286,13 +7502,13 @@ function applyStreamSweep(paths, dryRun, log) {
7286
7502
  }
7287
7503
 
7288
7504
  // src/cli.ts
7289
- import { readFileSync as readFileSync12, existsSync as existsSync18, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
7505
+ import { readFileSync as readFileSync12, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
7290
7506
  import { createInterface } from "readline/promises";
7291
7507
  import { dirname as dirOf, resolve as resolvePath } from "path";
7292
7508
 
7293
7509
  // src/ssh-keys.ts
7294
7510
  import { execFileSync as execFileSync3 } from "child_process";
7295
- import { existsSync as existsSync15, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
7511
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
7296
7512
  import { dirname as dirname8, join as join14 } from "path";
7297
7513
  var SshKeyError = class extends Error {
7298
7514
  };
@@ -7302,7 +7518,7 @@ function shipSshKeyPathFor(stateDir) {
7302
7518
  function ensureShipSshKeypair(stateDir) {
7303
7519
  const privateKeyPath = shipSshKeyPathFor(stateDir);
7304
7520
  const publicKeyPath = `${privateKeyPath}.pub`;
7305
- if (!existsSync15(privateKeyPath)) {
7521
+ if (!existsSync16(privateKeyPath)) {
7306
7522
  mkdirSync7(dirname8(privateKeyPath), { recursive: true });
7307
7523
  try {
7308
7524
  execFileSync3("ssh-keygen", [
@@ -7323,7 +7539,7 @@ function ensureShipSshKeypair(stateDir) {
7323
7539
  }
7324
7540
  chmodSync(privateKeyPath, 384);
7325
7541
  }
7326
- if (!existsSync15(publicKeyPath)) {
7542
+ if (!existsSync16(publicKeyPath)) {
7327
7543
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
7328
7544
  }
7329
7545
  const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
@@ -7516,7 +7732,7 @@ function syncAllPassengerCheckouts(cfg) {
7516
7732
 
7517
7733
  // src/tunnel.ts
7518
7734
  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";
7735
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
7520
7736
  import { randomInt } from "crypto";
7521
7737
  import { join as join15 } from "path";
7522
7738
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -7589,7 +7805,7 @@ function tunnelStatePath(stateDir, workspaceId) {
7589
7805
  }
7590
7806
  function readPersistedTunnel(stateDir, workspaceId) {
7591
7807
  const path = tunnelStatePath(stateDir, workspaceId);
7592
- if (!existsSync16(path)) return void 0;
7808
+ if (!existsSync17(path)) return void 0;
7593
7809
  try {
7594
7810
  return JSON.parse(readFileSync10(path, "utf8"));
7595
7811
  } catch {
@@ -7697,7 +7913,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
7697
7913
  }
7698
7914
  function readAllPersistedWorkspaceIds(stateDir) {
7699
7915
  const dir = join15(stateDir, "passengers");
7700
- if (!existsSync16(dir)) return [];
7916
+ if (!existsSync17(dir)) return [];
7701
7917
  return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
7702
7918
  }
7703
7919
 
@@ -7818,7 +8034,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
7818
8034
  import { createServer as createHttpServer } from "http";
7819
8035
  import { spawn as spawn4 } from "child_process";
7820
8036
  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";
8037
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
7822
8038
  import { join as join16 } from "path";
7823
8039
 
7824
8040
  // src/secret-compare.ts
@@ -7912,7 +8128,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
7912
8128
  }
7913
8129
  function readPersistedSyncDaemon(stateDir, workspaceId) {
7914
8130
  const path = syncDaemonStatePath(stateDir, workspaceId);
7915
- if (!existsSync17(path)) return void 0;
8131
+ if (!existsSync18(path)) return void 0;
7916
8132
  try {
7917
8133
  return JSON.parse(readFileSync11(path, "utf8"));
7918
8134
  } catch {
@@ -7943,7 +8159,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
7943
8159
  }
7944
8160
  function readAllPersistedSyncWorkspaceIds(stateDir) {
7945
8161
  const dir = join16(stateDir, "passengers");
7946
- if (!existsSync17(dir)) return [];
8162
+ if (!existsSync18(dir)) return [];
7947
8163
  const suffix = "-sync.json";
7948
8164
  return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
7949
8165
  }
@@ -8655,7 +8871,7 @@ if (command === "passenger-sync-daemon") {
8655
8871
  if (host === "linux" && hasSystemd) {
8656
8872
  const label = labelFor(CREW_HOME, "run");
8657
8873
  const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
8658
- if (existsSync18(staleTimer)) {
8874
+ if (existsSync19(staleTimer)) {
8659
8875
  process.stdout.write(
8660
8876
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
8661
8877
  `
@@ -8680,7 +8896,7 @@ if (command === "passenger-sync-daemon") {
8680
8896
  const tracker = new Tracker(c, cfg.ship);
8681
8897
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
8682
8898
  for (const r of await resolvedRepos(c)) {
8683
- if (!existsSync18(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
8899
+ if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
8684
8900
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
8685
8901
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
8686
8902
  }
@@ -8698,7 +8914,7 @@ if (command === "passenger-sync-daemon") {
8698
8914
  }
8699
8915
  async function doctorFix(o) {
8700
8916
  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);
8917
+ const unitExists = (p) => existsSync19(p);
8702
8918
  const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
8703
8919
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
8704
8920
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -8855,6 +9071,36 @@ ${changed2.map((c) => ` - ${c}
8855
9071
  } else if (outcome.stopped) {
8856
9072
  remit.emit(`nothing stamped \u2014 ${outcome.stopped}`);
8857
9073
  }
9074
+ for (const m of outcome.decision.merges) {
9075
+ if (m.skipReason !== "already-released" || !m.mergedSha) continue;
9076
+ const tag = firstReleaseTagContaining(target.dir, m.mergedSha, repo.release.tagPattern ?? "v*");
9077
+ if (!tag) continue;
9078
+ remit.enter("reconcile");
9079
+ await applyStamp(
9080
+ tracker,
9081
+ [{ ticket: m.ticket, reason: `carried by ${tag}`, sha: m.mergedSha }],
9082
+ tag.replace(/^v/, ""),
9083
+ tracker.contract,
9084
+ remit,
9085
+ dryRun,
9086
+ target.dir
9087
+ );
9088
+ }
9089
+ if (outcome.gateRed && !dryRun) {
9090
+ try {
9091
+ const g = outcome.gateRed;
9092
+ const comments = await tracker.comments();
9093
+ const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9094
+ for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
9095
+ if (alreadyReported(t, g, comments)) continue;
9096
+ await tracker.postEvent(t.id, gateRedComment(g), seat);
9097
+ remit.warn(`test gate red for ${g.count} cycles \u2014 noted on the ticket`, { ticket: t.issue_id, step: "merge" });
9098
+ }
9099
+ state.release(scope).noteTestGateReported(g.sha);
9100
+ } catch (e) {
9101
+ remit.warn(`could not note the red test gate on the board: ${e.message}`, { step: "merge" });
9102
+ }
9103
+ }
8858
9104
  if (outcome.externalClosures?.length) {
8859
9105
  await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
8860
9106
  }
@@ -8918,9 +9164,11 @@ ${changed2.map((c) => ` - ${c}
8918
9164
  const actions = planWorktreeSweep(target, scoped, tracker.contract);
8919
9165
  if (actions.length) {
8920
9166
  remit.enter("worktree");
8921
- const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
9167
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
8922
9168
  remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
8923
9169
  }
9170
+ const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
9171
+ if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
8924
9172
  } catch (e) {
8925
9173
  remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
8926
9174
  }
@@ -9574,7 +9822,7 @@ ${changed2.map((c) => ` - ${c}
9574
9822
  `);
9575
9823
  if (!dryRun) {
9576
9824
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9577
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9825
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9578
9826
  raw.agentPersonas = personaResult.agentPersonas;
9579
9827
  raw.agentSkills = skillResult.agentSkills;
9580
9828
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
@@ -9623,7 +9871,7 @@ ${changed2.map((c) => ` - ${c}
9623
9871
  `);
9624
9872
  if (!dryRun) {
9625
9873
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9626
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9874
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9627
9875
  raw.agentSkills = result.agentSkills;
9628
9876
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
9629
9877
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -9646,7 +9894,7 @@ ${changed2.map((c) => ` - ${c}
9646
9894
  if (sub === "list" && !routeName) usage2();
9647
9895
  if (sub === "add") {
9648
9896
  try {
9649
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync18(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
9897
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
9650
9898
  routeName = a.route;
9651
9899
  addPath = a.path;
9652
9900
  addName = a.name;
@@ -9661,7 +9909,7 @@ ${changed2.map((c) => ` - ${c}
9661
9909
  }
9662
9910
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
9663
9911
  let resolvedRemotes;
9664
- if (existsSync18(resolvedFile)) {
9912
+ if (existsSync19(resolvedFile)) {
9665
9913
  try {
9666
9914
  resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9667
9915
  } catch {
@@ -9678,14 +9926,14 @@ ${changed2.map((c) => ` - ${c}
9678
9926
  for (const r of repos) {
9679
9927
  const origin2 = originOf(r.dir);
9680
9928
  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)"}`;
9929
+ const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
9682
9930
  process.stdout.write(`${r.name} ${r.dir} ${note}
9683
9931
  `);
9684
9932
  }
9685
9933
  break;
9686
9934
  }
9687
9935
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
9688
- const top = existsSync18(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9936
+ const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9689
9937
  if (!top) throw new ReposError(`${target} is not a git checkout`);
9690
9938
  const origin = originOf(top);
9691
9939
  const plan = planRepoAdd({
@@ -9733,7 +9981,7 @@ ${changed2.map((c) => ` - ${c}
9733
9981
  const created = await rowTracker.createRepoRow(rowPayload);
9734
9982
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
9735
9983
  `);
9736
- if (existsSync18(resolvedFile)) {
9984
+ if (existsSync19(resolvedFile)) {
9737
9985
  const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9738
9986
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
9739
9987
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -9966,7 +10214,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
9966
10214
  operator = await pickOperator(found.holds);
9967
10215
  }
9968
10216
  let previous;
9969
- const hadPreviousFile = existsSync18(resolvedPath);
10217
+ const hadPreviousFile = existsSync19(resolvedPath);
9970
10218
  if (hadPreviousFile) {
9971
10219
  try {
9972
10220
  previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
@@ -10222,9 +10470,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10222
10470
  const { byRepo } = ticketsByRepo(route, terminal);
10223
10471
  for (const r of await resolvedRepos(route)) {
10224
10472
  const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
10473
+ const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
10474
+ if (stale.length) {
10475
+ anything = true;
10476
+ applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
10477
+ }
10225
10478
  if (!actions.length) continue;
10226
10479
  anything = true;
10227
- const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
10480
+ const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
10228
10481
  emit.emit(
10229
10482
  `swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
10230
10483
  { step: "worktree" }
@@ -10280,8 +10533,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10280
10533
  break;
10281
10534
  }
10282
10535
  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`);
10536
+ if (branch) {
10537
+ gitOk(target.dir, ["branch", "-D", branch]);
10538
+ const { remote, base } = target.config.branch;
10539
+ if (remoteConfigured(target.dir, remote) && remoteBranchExists(target.dir, remote, branch)) {
10540
+ if (findKeyInRange(target.dir, `ISSUE-${n}`, `${remote}/${base}`)) {
10541
+ emit.emit(deleteRemoteBranch(target.dir, remote, branch) ? `deleted ${remote}/${branch}` : `could not delete ${remote}/${branch}`);
10542
+ } else {
10543
+ emit.warn(`${remote}/${branch} kept \u2014 nothing on ${remote}/${base} names ISSUE-${n} yet, so it may be the only copy`);
10544
+ }
10545
+ }
10546
+ } else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
10285
10547
  emit.emit(`dropped ISSUE-${n} from ${target.name}`);
10286
10548
  break;
10287
10549
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.2.0",
3
+ "version": "0.4.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