@tablation/crew 0.1.7 → 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/README.md CHANGED
@@ -211,7 +211,7 @@ crew pause|resume [route] [R] pause everything, or one role
211
211
  crew log [route] tail the log
212
212
  crew inbox [--member NAME] your tickets across every workspace
213
213
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
214
- crew repos add ROUTE PATH attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
214
+ crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
215
215
  crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
216
216
  crew install write and load this platform's scheduler unit
217
217
  crew uninstall unload and remove it
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
  }
@@ -7042,6 +7111,27 @@ function matchRepoName(origin, resolved) {
7042
7111
  }
7043
7112
  return void 0;
7044
7113
  }
7114
+ function routeNamesOf(text) {
7115
+ const routes = parseDocument(text).get("routes", true);
7116
+ if (!isSeq(routes)) return [];
7117
+ return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
7118
+ }
7119
+ function parseAddArgs(args, routes, isDir) {
7120
+ const rest = [...args];
7121
+ let route;
7122
+ if (rest.length > 0 && routes.includes(rest[0])) route = rest.shift();
7123
+ else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
7124
+ throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
7125
+ }
7126
+ if (!route) {
7127
+ if (routes.length === 1) route = routes[0];
7128
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
7129
+ }
7130
+ if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
7131
+ if (rest.length === 2) return { route, name: rest[0], path: rest[1] };
7132
+ if (rest.length === 1) return isDir(rest[0]) ? { route, path: rest[0] } : { route, name: rest[0], path: "." };
7133
+ return { route, path: "." };
7134
+ }
7045
7135
  function expandDir(p, base) {
7046
7136
  const home = p === "~" ? homedir3() : p.startsWith("~/") ? `${homedir3()}/${p.slice(2)}` : p;
7047
7137
  return resolve3(base, home);
@@ -7081,7 +7171,7 @@ function planRepoAdd(i) {
7081
7171
  throw new ReposError(`${target} is already configured on route ${i.route}`);
7082
7172
  }
7083
7173
  const matched = matchRepoName(i.origin, i.resolved);
7084
- const name = matched ?? i.name ?? basename5(target);
7174
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename5(target);
7085
7175
  let warning;
7086
7176
  if (!matched) {
7087
7177
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -7178,6 +7268,7 @@ function schedulerInstalled(plan, exists, crontab) {
7178
7268
  }
7179
7269
 
7180
7270
  // src/worktree-sweep.ts
7271
+ import { existsSync as existsSync14 } from "fs";
7181
7272
  import { basename as basename6 } from "path";
7182
7273
  function planWorktreeSweep(target, tickets, contract) {
7183
7274
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -7197,10 +7288,24 @@ function planWorktreeSweep(target, tickets, contract) {
7197
7288
  }
7198
7289
  return actions;
7199
7290
  }
7200
- async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7291
+ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
7201
7292
  let removed = 0;
7202
7293
  let keptBranches = 0;
7203
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
+ }
7204
7309
  const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
7205
7310
  const pids = pidsInWorktree(a.path, n);
7206
7311
  if (pids.length) {
@@ -7220,7 +7325,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7220
7325
  continue;
7221
7326
  }
7222
7327
  gitOk(cwd, ["worktree", "remove", "--force", a.path]);
7223
- 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
+ }
7224
7334
  log.emit(
7225
7335
  `removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
7226
7336
  { ticket: a.ticket.issue_id, step: "worktree" }
@@ -7229,14 +7339,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
7229
7339
  }
7230
7340
  return { removed, keptBranches };
7231
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
+ }
7232
7381
 
7233
7382
  // src/stream-sweep.ts
7234
- 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";
7235
7384
  import { join as join13 } from "path";
7236
7385
  var DAY_MS = 24 * 60 * 60 * 1e3;
7237
7386
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
7238
7387
  const dir = join13(stateDir, "streams");
7239
- if (!existsSync14(dir)) return [];
7388
+ if (!existsSync15(dir)) return [];
7240
7389
  const cutoff = now - retentionDays * DAY_MS;
7241
7390
  return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
7242
7391
  try {
@@ -7265,13 +7414,13 @@ function applyStreamSweep(paths, dryRun, log) {
7265
7414
  }
7266
7415
 
7267
7416
  // src/cli.ts
7268
- 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";
7269
7418
  import { createInterface } from "readline/promises";
7270
7419
  import { dirname as dirOf, resolve as resolvePath } from "path";
7271
7420
 
7272
7421
  // src/ssh-keys.ts
7273
7422
  import { execFileSync as execFileSync3 } from "child_process";
7274
- 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";
7275
7424
  import { dirname as dirname8, join as join14 } from "path";
7276
7425
  var SshKeyError = class extends Error {
7277
7426
  };
@@ -7281,7 +7430,7 @@ function shipSshKeyPathFor(stateDir) {
7281
7430
  function ensureShipSshKeypair(stateDir) {
7282
7431
  const privateKeyPath = shipSshKeyPathFor(stateDir);
7283
7432
  const publicKeyPath = `${privateKeyPath}.pub`;
7284
- if (!existsSync15(privateKeyPath)) {
7433
+ if (!existsSync16(privateKeyPath)) {
7285
7434
  mkdirSync7(dirname8(privateKeyPath), { recursive: true });
7286
7435
  try {
7287
7436
  execFileSync3("ssh-keygen", [
@@ -7302,7 +7451,7 @@ function ensureShipSshKeypair(stateDir) {
7302
7451
  }
7303
7452
  chmodSync(privateKeyPath, 384);
7304
7453
  }
7305
- if (!existsSync15(publicKeyPath)) {
7454
+ if (!existsSync16(publicKeyPath)) {
7306
7455
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
7307
7456
  }
7308
7457
  const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
@@ -7495,7 +7644,7 @@ function syncAllPassengerCheckouts(cfg) {
7495
7644
 
7496
7645
  // src/tunnel.ts
7497
7646
  import { spawn as spawn3 } from "child_process";
7498
- 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";
7499
7648
  import { randomInt } from "crypto";
7500
7649
  import { join as join15 } from "path";
7501
7650
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -7568,7 +7717,7 @@ function tunnelStatePath(stateDir, workspaceId) {
7568
7717
  }
7569
7718
  function readPersistedTunnel(stateDir, workspaceId) {
7570
7719
  const path = tunnelStatePath(stateDir, workspaceId);
7571
- if (!existsSync16(path)) return void 0;
7720
+ if (!existsSync17(path)) return void 0;
7572
7721
  try {
7573
7722
  return JSON.parse(readFileSync10(path, "utf8"));
7574
7723
  } catch {
@@ -7676,7 +7825,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
7676
7825
  }
7677
7826
  function readAllPersistedWorkspaceIds(stateDir) {
7678
7827
  const dir = join15(stateDir, "passengers");
7679
- if (!existsSync16(dir)) return [];
7828
+ if (!existsSync17(dir)) return [];
7680
7829
  return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
7681
7830
  }
7682
7831
 
@@ -7797,7 +7946,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
7797
7946
  import { createServer as createHttpServer } from "http";
7798
7947
  import { spawn as spawn4 } from "child_process";
7799
7948
  import { randomBytes } from "crypto";
7800
- 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";
7801
7950
  import { join as join16 } from "path";
7802
7951
 
7803
7952
  // src/secret-compare.ts
@@ -7891,7 +8040,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
7891
8040
  }
7892
8041
  function readPersistedSyncDaemon(stateDir, workspaceId) {
7893
8042
  const path = syncDaemonStatePath(stateDir, workspaceId);
7894
- if (!existsSync17(path)) return void 0;
8043
+ if (!existsSync18(path)) return void 0;
7895
8044
  try {
7896
8045
  return JSON.parse(readFileSync11(path, "utf8"));
7897
8046
  } catch {
@@ -7922,7 +8071,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
7922
8071
  }
7923
8072
  function readAllPersistedSyncWorkspaceIds(stateDir) {
7924
8073
  const dir = join16(stateDir, "passengers");
7925
- if (!existsSync17(dir)) return [];
8074
+ if (!existsSync18(dir)) return [];
7926
8075
  const suffix = "-sync.json";
7927
8076
  return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
7928
8077
  }
@@ -8327,7 +8476,7 @@ function usage() {
8327
8476
  crew agents sync [route] [--force] push crew's personas AND skill files into the workspace Agents/Agent Skills tables (--force overwrites even a diverged persona)
8328
8477
  crew agents prompt R [route] print one persona's current prompt (e.g. R=pair, for a SessionStart hook)
8329
8478
  crew skills sync [route] push only crew's skill files (e.g. grill-me) into the workspace Agent Skills table
8330
- crew repos add ROUTE PATH attach a local checkout to a route in crew.yaml (--dry-run previews)
8479
+ crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml (--dry-run previews)
8331
8480
  crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches
8332
8481
  crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket
8333
8482
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
@@ -8634,7 +8783,7 @@ if (command === "passenger-sync-daemon") {
8634
8783
  if (host === "linux" && hasSystemd) {
8635
8784
  const label = labelFor(CREW_HOME, "run");
8636
8785
  const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
8637
- if (existsSync18(staleTimer)) {
8786
+ if (existsSync19(staleTimer)) {
8638
8787
  process.stdout.write(
8639
8788
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
8640
8789
  `
@@ -8659,7 +8808,7 @@ if (command === "passenger-sync-daemon") {
8659
8808
  const tracker = new Tracker(c, cfg.ship);
8660
8809
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
8661
8810
  for (const r of await resolvedRepos(c)) {
8662
- 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`);
8663
8812
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
8664
8813
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
8665
8814
  }
@@ -8677,7 +8826,7 @@ if (command === "passenger-sync-daemon") {
8677
8826
  }
8678
8827
  async function doctorFix(o) {
8679
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))));
8680
- const unitExists = (p) => existsSync18(p);
8829
+ const unitExists = (p) => existsSync19(p);
8681
8830
  const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
8682
8831
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
8683
8832
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -8897,9 +9046,11 @@ ${changed2.map((c) => ` - ${c}
8897
9046
  const actions = planWorktreeSweep(target, scoped, tracker.contract);
8898
9047
  if (actions.length) {
8899
9048
  remit.enter("worktree");
8900
- const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
9049
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
8901
9050
  remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
8902
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);
8903
9054
  } catch (e) {
8904
9055
  remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
8905
9056
  }
@@ -9553,7 +9704,7 @@ ${changed2.map((c) => ` - ${c}
9553
9704
  `);
9554
9705
  if (!dryRun) {
9555
9706
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9556
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9707
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9557
9708
  raw.agentPersonas = personaResult.agentPersonas;
9558
9709
  raw.agentSkills = skillResult.agentSkills;
9559
9710
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
@@ -9602,7 +9753,7 @@ ${changed2.map((c) => ` - ${c}
9602
9753
  `);
9603
9754
  if (!dryRun) {
9604
9755
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
9605
- const raw = existsSync18(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9756
+ const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
9606
9757
  raw.agentSkills = result.agentSkills;
9607
9758
  mkdirSync10(dirOf(resolvedPath), { recursive: true });
9608
9759
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -9613,22 +9764,41 @@ ${changed2.map((c) => ` - ${c}
9613
9764
  }
9614
9765
  case "repos": {
9615
9766
  const sub = positional[1];
9616
- const routeName = positional[2];
9617
9767
  const usage2 = () => {
9618
- process.stderr.write("crew repos add <workspace/project> <path> [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos list <workspace/project>\n");
9768
+ process.stderr.write("crew repos add [workspace/project] [name] [path] [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos list <workspace/project>\n");
9619
9769
  process.exit(2);
9620
9770
  };
9621
- if (sub !== "add" && sub !== "list" || !routeName || sub === "add" && !positional[3]) usage2();
9771
+ if (sub !== "add" && sub !== "list") usage2();
9772
+ const configText = readFileSync12(cfg.configFile, "utf8");
9773
+ let routeName = positional[2];
9774
+ let addPath = ".";
9775
+ let addName;
9776
+ if (sub === "list" && !routeName) usage2();
9777
+ if (sub === "add") {
9778
+ try {
9779
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
9780
+ routeName = a.route;
9781
+ addPath = a.path;
9782
+ addName = a.name;
9783
+ } catch (e) {
9784
+ if (e instanceof ReposError) {
9785
+ process.stderr.write(`crew repos: ${e.message}
9786
+ `);
9787
+ process.exit(2);
9788
+ }
9789
+ throw e;
9790
+ }
9791
+ }
9622
9792
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
9623
9793
  let resolvedRemotes;
9624
- if (existsSync18(resolvedFile)) {
9794
+ if (existsSync19(resolvedFile)) {
9625
9795
  try {
9626
9796
  resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9627
9797
  } catch {
9628
9798
  }
9629
9799
  }
9630
9800
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
9631
- const text = readFileSync12(cfg.configFile, "utf8");
9801
+ const text = configText;
9632
9802
  const base = dirname9(cfg.configFile);
9633
9803
  try {
9634
9804
  if (sub === "list") {
@@ -9638,14 +9808,14 @@ ${changed2.map((c) => ` - ${c}
9638
9808
  for (const r of repos) {
9639
9809
  const origin2 = originOf(r.dir);
9640
9810
  const row = matchRepoName(origin2, resolvedRemotes);
9641
- 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)"}`;
9642
9812
  process.stdout.write(`${r.name} ${r.dir} ${note}
9643
9813
  `);
9644
9814
  }
9645
9815
  break;
9646
9816
  }
9647
- const target = resolvePath(positional[3].replace(/^~(?=\/|$)/, homedir4()));
9648
- const top = existsSync18(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9817
+ const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
9818
+ const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
9649
9819
  if (!top) throw new ReposError(`${target} is not a git checkout`);
9650
9820
  const origin = originOf(top);
9651
9821
  const plan = planRepoAdd({
@@ -9655,7 +9825,7 @@ ${changed2.map((c) => ` - ${c}
9655
9825
  path: top,
9656
9826
  origin,
9657
9827
  resolved: resolvedRemotes,
9658
- name: value("name"),
9828
+ name: value("name") ?? addName,
9659
9829
  nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes)
9660
9830
  });
9661
9831
  let rowTracker;
@@ -9693,7 +9863,7 @@ ${changed2.map((c) => ` - ${c}
9693
9863
  const created = await rowTracker.createRepoRow(rowPayload);
9694
9864
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
9695
9865
  `);
9696
- if (existsSync18(resolvedFile)) {
9866
+ if (existsSync19(resolvedFile)) {
9697
9867
  const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
9698
9868
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
9699
9869
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -9926,7 +10096,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
9926
10096
  operator = await pickOperator(found.holds);
9927
10097
  }
9928
10098
  let previous;
9929
- const hadPreviousFile = existsSync18(resolvedPath);
10099
+ const hadPreviousFile = existsSync19(resolvedPath);
9930
10100
  if (hadPreviousFile) {
9931
10101
  try {
9932
10102
  previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
@@ -10182,9 +10352,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10182
10352
  const { byRepo } = ticketsByRepo(route, terminal);
10183
10353
  for (const r of await resolvedRepos(route)) {
10184
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
+ }
10185
10360
  if (!actions.length) continue;
10186
10361
  anything = true;
10187
- const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
10362
+ const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
10188
10363
  emit.emit(
10189
10364
  `swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
10190
10365
  { step: "worktree" }
@@ -10240,8 +10415,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10240
10415
  break;
10241
10416
  }
10242
10417
  gitOk(target.dir, ["worktree", "remove", "--force", wt]);
10243
- if (branch) gitOk(target.dir, ["branch", "-D", branch]);
10244
- 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`);
10245
10429
  emit.emit(`dropped ISSUE-${n} from ${target.name}`);
10246
10430
  break;
10247
10431
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.1.7",
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