@tablation/crew 0.10.2 → 0.12.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve4, dirname as dirname9, join as join19, basename as basename7 } from "path";
4
+ import { resolve as resolve4, dirname as dirname10, join as join19, basename as basename7 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
@@ -1682,7 +1682,8 @@ var DEFAULT_CONTRACT = {
1682
1682
  reportType: "report_type",
1683
1683
  epic: "epic_id",
1684
1684
  author: "filed_by_id",
1685
- ship: "ship_id"
1685
+ ship: "ship_id",
1686
+ heldBy: "held_by_ship_id"
1686
1687
  },
1687
1688
  comments: {
1688
1689
  parent: "ticket_id",
@@ -2971,6 +2972,20 @@ var Tracker = class {
2971
2972
  ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
2972
2973
  });
2973
2974
  }
2975
+ /**
2976
+ * Ship affinity for a claim (CREW-1386): stamp this ship's Ships row on the
2977
+ * ticket in the claim's own write, in the `held_by_ship_id` column (not
2978
+ * `ship_id`, which means "filed from"). `undefined` — claim exactly as
2979
+ * before — when the Issues table has no hold column or this ship has no row.
2980
+ */
2981
+ async claimAffinity() {
2982
+ const auth = await this.authorshipProbe();
2983
+ if (!auth.issueHeld || !auth.shipId) return void 0;
2984
+ return {
2985
+ column: this.contract.columns.heldBy,
2986
+ shipId: auth.shipId
2987
+ };
2988
+ }
2974
2989
  /**
2975
2990
  * The authorship probe (CREW-1371), read once per process: which optional
2976
2991
  * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
@@ -2992,6 +3007,7 @@ var Tracker = class {
2992
3007
  return {
2993
3008
  issueAuthor: issueFields.has(this.contract.columns.author),
2994
3009
  issueShip: issueFields.has(this.contract.columns.ship),
3010
+ issueHeld: issueFields.has(this.contract.columns.heldBy),
2995
3011
  commentShip: commentFields.has(this.contract.comments.ship),
2996
3012
  shipId: shipRow?.id ?? null
2997
3013
  };
@@ -3013,6 +3029,7 @@ var Tracker = class {
3013
3029
  const missing = [];
3014
3030
  if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
3015
3031
  if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
3032
+ if (!auth.issueHeld) missing.push(`Issues.${this.contract.columns.heldBy}`);
3016
3033
  if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
3017
3034
  return missing;
3018
3035
  }
@@ -3333,6 +3350,60 @@ function agedRankScalar(rawRank, aging, now) {
3333
3350
  return Math.round(rawRank * (1 - fraction));
3334
3351
  }
3335
3352
 
3353
+ // src/ship-affinity.ts
3354
+ var CYCLE_SECONDS = 120;
3355
+ var SHIP_DEAD_AFTER_CYCLES = 10;
3356
+ function shipIsAlive(ship, now) {
3357
+ if (!ship?.last_seen) return false;
3358
+ const seen = Date.parse(ship.last_seen);
3359
+ return Number.isFinite(seen) && now - seen <= SHIP_DEAD_AFTER_CYCLES * CYCLE_SECONDS * 1e3;
3360
+ }
3361
+ function ticketHolderShipId(t, contract) {
3362
+ const v = t[contract.columns.heldBy];
3363
+ return typeof v === "string" && v ? v : null;
3364
+ }
3365
+ function heldByOtherShip(t, a) {
3366
+ if (!a.myShipId) return false;
3367
+ const statuses = a.contract.statuses;
3368
+ if ([statuses.handoff, statuses.verifying].includes(t.status)) return false;
3369
+ const holder = ticketHolderShipId(t, a.contract);
3370
+ if (!holder || holder === a.myShipId) return false;
3371
+ const owner = a.ships.find((s) => s.id === holder);
3372
+ return shipIsAlive(owner, a.now ?? Date.now());
3373
+ }
3374
+ function shipIdByName(ships, name) {
3375
+ const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
3376
+ return mine.length === 1 ? mine[0].id : null;
3377
+ }
3378
+ async function noteReaccepted(tracker, result, seat, shipLabel) {
3379
+ if (!result.reaccepted || !result.ticket) return;
3380
+ try {
3381
+ await tracker.postEvent(
3382
+ result.ticket.id,
3383
+ `Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
3384
+ seat
3385
+ );
3386
+ } catch {
3387
+ }
3388
+ }
3389
+ function planHoldReleases(tickets, contract) {
3390
+ const ends = [contract.statuses.handoff, contract.statuses.verified];
3391
+ return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
3392
+ }
3393
+ async function applyHoldReleases(writer, tickets, contract, emit) {
3394
+ let cleared = 0;
3395
+ for (const t of tickets) {
3396
+ try {
3397
+ await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
3398
+ cleared++;
3399
+ emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
3400
+ } catch (e) {
3401
+ emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
3402
+ }
3403
+ }
3404
+ return cleared;
3405
+ }
3406
+
3336
3407
  // src/select.ts
3337
3408
  var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
3338
3409
  var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
@@ -3344,10 +3415,23 @@ function sliceFor(tickets, role) {
3344
3415
  return buildingSlice(tickets, role);
3345
3416
  }
3346
3417
  var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
3418
+ function affinityContext(i) {
3419
+ return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
3420
+ }
3421
+ function withHoldCheck(affinity, i) {
3422
+ if (!affinity) return void 0;
3423
+ const a = affinityContext(i);
3424
+ return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a) } : affinity;
3425
+ }
3426
+ function notHeldByOtherShip(slice, i) {
3427
+ if (!i.ships) return slice;
3428
+ const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
3429
+ return slice.filter((t) => !heldByOtherShip(t, a));
3430
+ }
3347
3431
  function buildingRoleHasWork(role, i) {
3348
3432
  const me = i.seats[role];
3349
3433
  if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
3350
- const mine = sliceFor(i.tickets, role);
3434
+ const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
3351
3435
  const startable = mine.filter(
3352
3436
  (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
3353
3437
  );
@@ -3397,7 +3481,7 @@ function roleCandidates(role, i) {
3397
3481
  if (role === "pair") return [];
3398
3482
  const me = i.seats[role];
3399
3483
  if (!me) return [];
3400
- return sliceFor(i.tickets, role).filter(
3484
+ return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
3401
3485
  (t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
3402
3486
  );
3403
3487
  }
@@ -3991,16 +4075,20 @@ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
3991
4075
  const [behindStr, aheadStr] = counts.split(/\s+/);
3992
4076
  const behind = Number.parseInt(behindStr ?? "0", 10);
3993
4077
  const ahead = Number.parseInt(aheadStr ?? "0", 10);
4078
+ const named = () => {
4079
+ const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
4080
+ return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
4081
+ };
3994
4082
  if (ahead > 0 && behind > 0) {
3995
4083
  return {
3996
4084
  action: "diverged",
3997
4085
  ahead,
3998
4086
  behind,
3999
- detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)`
4087
+ detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
4000
4088
  };
4001
4089
  }
4002
4090
  if (behind === 0 && ahead > 0) {
4003
- return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not` };
4091
+ return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not${named()}` };
4004
4092
  }
4005
4093
  if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
4006
4094
  if (dryRun) {
@@ -4015,8 +4103,22 @@ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
4015
4103
  detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
4016
4104
  };
4017
4105
  }
4018
- function pushBranch(cwd, remote, branch) {
4019
- git(cwd, ["push", remote, branch]);
4106
+ function recoverStrandedReleaseCheckouts(cwd) {
4107
+ gitOk(cwd, ["worktree", "prune"]);
4108
+ const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
4109
+ let path = "";
4110
+ for (const line of out.split("\n")) {
4111
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
4112
+ else if (line.startsWith("branch refs/heads/crew/release-") && path) {
4113
+ gitOk(cwd, ["worktree", "remove", "--force", path]);
4114
+ }
4115
+ }
4116
+ const stranded = (gitOk(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/crew/release-*"]) ?? "").split("\n").filter(Boolean);
4117
+ for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
4118
+ return stranded;
4119
+ }
4120
+ function pushBranch(cwd, remote, branch, dst = branch) {
4121
+ git(cwd, ["push", remote, `${branch}:${dst}`]);
4020
4122
  }
4021
4123
  function worktrees(cwd) {
4022
4124
  const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
@@ -4202,6 +4304,7 @@ async function decideCycle(o) {
4202
4304
  emit.warn(`ticket(s) blocking themselves, parked permanently: ${diag.selfBlocked.join(", ")}`);
4203
4305
  }
4204
4306
  const sweep = planSweep(tickets, info, blocked);
4307
+ const holdReleases = planHoldReleases(tickets, tracker.contract);
4205
4308
  const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
4206
4309
  emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
4207
4310
  const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
@@ -4269,7 +4372,8 @@ async function decideCycle(o) {
4269
4372
  holds: holds2,
4270
4373
  seats: route.resolved.seats,
4271
4374
  paused: state.pausedRoles(["dev", "design", "qa"]),
4272
- contract: tracker.contract
4375
+ contract: tracker.contract,
4376
+ ships: { myShipId: shipIdByName(ships, ship.name), rows: ships }
4273
4377
  };
4274
4378
  const selection = selectRole(selectionInput);
4275
4379
  const actionable = actionableSummary(selectionInput, selection.pending);
@@ -4282,6 +4386,7 @@ async function decideCycle(o) {
4282
4386
  info,
4283
4387
  sweep,
4284
4388
  epicSync,
4389
+ holdReleases,
4285
4390
  stranded,
4286
4391
  selection,
4287
4392
  actionable,
@@ -4382,22 +4487,62 @@ function rosterFor(d, route, role) {
4382
4487
 
4383
4488
  // src/claim.ts
4384
4489
  import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
4385
- async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable) {
4490
+ async function stampResumption(tracker, t, affinity) {
4491
+ let current = t;
4492
+ for (let attempt = 0; attempt < 2; attempt++) {
4493
+ try {
4494
+ const stamped = await tracker.updateTicket(current.id, { [affinity.column]: affinity.shipId }, current.updated_at);
4495
+ return { outcome: "stamped", ticket: { ...current, ...stamped } };
4496
+ } catch (e) {
4497
+ if (!(e instanceof StaleWriteError4)) throw e;
4498
+ }
4499
+ if (!tracker.ticket) return { outcome: "unknown" };
4500
+ try {
4501
+ current = await tracker.ticket(t.id);
4502
+ } catch {
4503
+ return { outcome: "unknown" };
4504
+ }
4505
+ if (affinity.isHeldElsewhere?.(current)) return { outcome: "held" };
4506
+ if (current[affinity.column] === affinity.shipId) return { outcome: "stamped", ticket: current };
4507
+ }
4508
+ return { outcome: "unknown" };
4509
+ }
4510
+ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity) {
4386
4511
  const contended = [];
4387
4512
  const unservable = [];
4513
+ const held = [];
4388
4514
  for (const t of candidates) {
4515
+ if (affinity?.isHeldElsewhere?.(t)) {
4516
+ held.push(t.issue_id);
4517
+ continue;
4518
+ }
4389
4519
  if (isServable && !isServable(t)) {
4390
4520
  unservable.push(t.issue_id);
4391
4521
  continue;
4392
4522
  }
4393
- if (t.status !== approvedStatus) return { ticket: t, claimed: false, contended, unservable };
4523
+ if (t.status !== approvedStatus) {
4524
+ if (affinity && t[affinity.column] !== affinity.shipId) {
4525
+ const stamp = await stampResumption(tracker, t, affinity);
4526
+ if (stamp.outcome === "stamped") {
4527
+ return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
4528
+ }
4529
+ (stamp.outcome === "held" ? held : contended).push(t.issue_id);
4530
+ continue;
4531
+ }
4532
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
4533
+ }
4394
4534
  try {
4395
4535
  const claimed = await tracker.updateTicket(
4396
4536
  t.id,
4397
- { status: buildingStatus, assignee_id: seat },
4537
+ {
4538
+ status: buildingStatus,
4539
+ assignee_id: seat,
4540
+ ...affinity ? { [affinity.column]: affinity.shipId } : {}
4541
+ },
4398
4542
  t.updated_at
4399
4543
  );
4400
- return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable };
4544
+ const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
4545
+ return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
4401
4546
  } catch (e) {
4402
4547
  if (e instanceof StaleWriteError4) {
4403
4548
  contended.push(t.issue_id);
@@ -4406,7 +4551,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4406
4551
  throw e;
4407
4552
  }
4408
4553
  }
4409
- return { ticket: null, claimed: false, contended, unservable };
4554
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
4410
4555
  }
4411
4556
 
4412
4557
  // src/conflict.ts
@@ -5453,8 +5598,9 @@ Wrote ${o.configPath}.
5453
5598
  }
5454
5599
 
5455
5600
  // src/release-run.ts
5456
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
5457
- import { join as join9 } from "path";
5601
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
5602
+ import { dirname as dirname7, join as join9 } from "path";
5603
+ import { tmpdir as tmpdir2 } from "os";
5458
5604
 
5459
5605
  // src/release.ts
5460
5606
  function describeUnplaceable(unplaceable, verifiedStatus) {
@@ -6029,6 +6175,31 @@ async function confirmLastRelease(o, decision) {
6029
6175
  if (!confirmed) o.emit.warn(`${decision.lastTag} is still not confirmed live \u2014 tickets stay verified`);
6030
6176
  return { confirmed, version };
6031
6177
  }
6178
+ function openReleaseCheckout(o) {
6179
+ const { remote, base } = o.repo.branch;
6180
+ const branch = `crew/release-${Date.now()}`;
6181
+ const root = mkdtempSync(join9(tmpdir2(), "crew-release-"));
6182
+ const dir = join9(root, "checkout");
6183
+ const close = () => {
6184
+ gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
6185
+ rmSync2(root, { recursive: true, force: true });
6186
+ gitOk(o.cwd, ["worktree", "prune"]);
6187
+ gitOk(o.cwd, ["branch", "-D", branch]);
6188
+ };
6189
+ try {
6190
+ git(o.cwd, ["worktree", "add", "-q", "-b", branch, dir, `${remote}/${base}`]);
6191
+ } catch (e) {
6192
+ close();
6193
+ throw e;
6194
+ }
6195
+ for (const f of o.repo.worktrees.copy) {
6196
+ const src = join9(o.cwd, f);
6197
+ if (!existsSync9(src)) continue;
6198
+ mkdirSync7(dirname7(join9(dir, f)), { recursive: true });
6199
+ copyFileSync(src, join9(dir, f));
6200
+ }
6201
+ return { dir, branch, close };
6202
+ }
6032
6203
  async function runRelease(o) {
6033
6204
  const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
6034
6205
  const tagPattern = o.repo.release.tagPattern ?? "v*";
@@ -6075,6 +6246,33 @@ async function runRelease(o) {
6075
6246
  const externalClosures = await confirmExternalReleased(o, landed);
6076
6247
  return { merged: [], deployed: false, stopped: "external", decision, externalClosures };
6077
6248
  }
6249
+ const { remote, base } = o.repo.branch;
6250
+ if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
6251
+ return mergeAndRelease(o, decision, fresh);
6252
+ }
6253
+ let checkout;
6254
+ const stranded = recoverStrandedReleaseCheckouts(o.cwd);
6255
+ if (stranded.length > 0) o.emit.warn(scoped(`dropped release checkout(s) stranded by a crash: ${stranded.join(", ")}`));
6256
+ try {
6257
+ checkout = openReleaseCheckout(o);
6258
+ } catch (e) {
6259
+ o.emit.error(scoped(`could not cut a release checkout from ${remote}/${base} \u2014 ${e.message}`));
6260
+ return { merged: [], deployed: false, stopped: "release checkout failed", decision };
6261
+ }
6262
+ try {
6263
+ return await mergeAndRelease({ ...o, cwd: checkout.dir }, decision, fresh, checkout.branch);
6264
+ } finally {
6265
+ checkout.close();
6266
+ const refreshed = refreshBaseBranch(o.cwd, remote, base);
6267
+ if (refreshed.action === "fast-forwarded" && o.repo.hooks.build) {
6268
+ const r = await hook(o, "build");
6269
+ if (r && r.code !== 0) {
6270
+ o.emit.warn(scoped(`rebuilding the primary checkout after the release failed (exit ${r.code}) \u2014 its dist/ is stale until \`${o.repo.hooks.build}\` is run there`));
6271
+ }
6272
+ }
6273
+ }
6274
+ }
6275
+ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
6078
6276
  o.emit.enter("merge");
6079
6277
  const merged = [];
6080
6278
  const conflicts = [];
@@ -6236,6 +6434,16 @@ async function runRelease(o) {
6236
6434
  }
6237
6435
  }
6238
6436
  }
6437
+ if (releaseBranch && !o.dryRun) {
6438
+ const { remote, base } = o.repo.branch;
6439
+ if (fetchRemote(o.cwd, remote) && remoteBranchExists(o.cwd, remote, base)) {
6440
+ const moved = resolve2(o.cwd, `${remote}/${base}`);
6441
+ if (moved && !isAncestor(o.cwd, moved, "HEAD")) {
6442
+ o.emit.warn(`${remote}/${base} moved during the release \u2014 discarding it; the tickets stay verified and the next cycle retries`);
6443
+ return { merged, conflicts, unbuildable, version, deployed: false, decision, stopped: "base branch moved during release" };
6444
+ }
6445
+ }
6446
+ }
6239
6447
  let deployed = false;
6240
6448
  let integrated = false;
6241
6449
  if (o.repo.release.mode === "integrate") {
@@ -6265,7 +6473,7 @@ async function runRelease(o) {
6265
6473
  o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6266
6474
  } else {
6267
6475
  try {
6268
- pushBranch(o.cwd, o.repo.branch.remote, o.repo.branch.base);
6476
+ pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
6269
6477
  o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6270
6478
  } catch (e) {
6271
6479
  o.emit.error(
@@ -8020,7 +8228,7 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
8020
8228
  }
8021
8229
 
8022
8230
  // src/stream-sweep.ts
8023
- import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync2, statSync as statSync6 } from "fs";
8231
+ import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
8024
8232
  import { join as join14 } from "path";
8025
8233
  var DAY_MS = 24 * 60 * 60 * 1e3;
8026
8234
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
@@ -8043,7 +8251,7 @@ function applyStreamSweep(paths, dryRun, log) {
8043
8251
  continue;
8044
8252
  }
8045
8253
  try {
8046
- rmSync2(path, { force: true });
8254
+ rmSync3(path, { force: true });
8047
8255
  removed++;
8048
8256
  log.emit(`removed stream artifact ${path}`, { step: "worktree" });
8049
8257
  } catch (e) {
@@ -8054,14 +8262,14 @@ function applyStreamSweep(paths, dryRun, log) {
8054
8262
  }
8055
8263
 
8056
8264
  // src/cli.ts
8057
- import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
8265
+ import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
8058
8266
  import { createInterface } from "readline/promises";
8059
8267
  import { dirname as dirOf, resolve as resolvePath } from "path";
8060
8268
 
8061
8269
  // src/ssh-keys.ts
8062
8270
  import { execFileSync as execFileSync3 } from "child_process";
8063
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync11, chmodSync } from "fs";
8064
- import { dirname as dirname8, join as join15 } from "path";
8271
+ import { existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
8272
+ import { dirname as dirname9, join as join15 } from "path";
8065
8273
  var SshKeyError = class extends Error {
8066
8274
  };
8067
8275
  function shipSshKeyPathFor(stateDir) {
@@ -8071,7 +8279,7 @@ function ensureShipSshKeypair(stateDir) {
8071
8279
  const privateKeyPath = shipSshKeyPathFor(stateDir);
8072
8280
  const publicKeyPath = `${privateKeyPath}.pub`;
8073
8281
  if (!existsSync16(privateKeyPath)) {
8074
- mkdirSync7(dirname8(privateKeyPath), { recursive: true });
8282
+ mkdirSync8(dirname9(privateKeyPath), { recursive: true });
8075
8283
  try {
8076
8284
  execFileSync3("ssh-keygen", [
8077
8285
  "-t",
@@ -8308,7 +8516,7 @@ function syncAllPassengerCheckouts(cfg) {
8308
8516
 
8309
8517
  // src/tunnel.ts
8310
8518
  import { spawn as spawn3 } from "child_process";
8311
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8519
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8312
8520
  import { randomInt } from "crypto";
8313
8521
  import { join as join16 } from "path";
8314
8522
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -8390,7 +8598,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
8390
8598
  }
8391
8599
  function writePersistedTunnel(stateDir, state) {
8392
8600
  const path = tunnelStatePath(stateDir, state.workspaceId);
8393
- mkdirSync8(join16(stateDir, "passengers"), { recursive: true });
8601
+ mkdirSync9(join16(stateDir, "passengers"), { recursive: true });
8394
8602
  writeFileSync7(path, `${JSON.stringify(state, null, 2)}
8395
8603
  `);
8396
8604
  }
@@ -8610,7 +8818,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
8610
8818
  import { createServer as createHttpServer } from "http";
8611
8819
  import { spawn as spawn4 } from "child_process";
8612
8820
  import { randomBytes } from "crypto";
8613
- import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
8821
+ import { existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
8614
8822
  import { join as join17 } from "path";
8615
8823
 
8616
8824
  // src/secret-compare.ts
@@ -8713,7 +8921,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
8713
8921
  }
8714
8922
  function writePersistedSyncDaemon(stateDir, state) {
8715
8923
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
8716
- mkdirSync9(join17(stateDir, "passengers"), { recursive: true });
8924
+ mkdirSync10(join17(stateDir, "passengers"), { recursive: true });
8717
8925
  writeFileSync8(path, `${JSON.stringify(state, null, 2)}
8718
8926
  `);
8719
8927
  }
@@ -8895,6 +9103,10 @@ async function runOnePass(o) {
8895
9103
  emit.warn(`could not sweep: ${e.message}`, { step: "sweep" });
8896
9104
  }
8897
9105
  }
9106
+ if (decision.holdReleases?.length) {
9107
+ const tracker = new Tracker(o.route, o.ship);
9108
+ await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
9109
+ }
8898
9110
  const order = [...decision.selection.pending].sort(
8899
9111
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
8900
9112
  );
@@ -8971,7 +9183,8 @@ async function runRoleAgent(role, decision, ctx) {
8971
9183
  seat,
8972
9184
  contract.statuses.approved,
8973
9185
  contract.statuses.building,
8974
- (t) => dirForRepo(route, t.repo_id) !== null
9186
+ (t) => dirForRepo(route, t.repo_id) !== null,
9187
+ withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
8975
9188
  );
8976
9189
  if (result.contended.length) {
8977
9190
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -8985,6 +9198,10 @@ async function runRoleAgent(role, decision, ctx) {
8985
9198
  role
8986
9199
  });
8987
9200
  }
9201
+ if (result.held.length) {
9202
+ emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
9203
+ }
9204
+ await noteReaccepted(tracker, result, seat, ship.name);
8988
9205
  if (!result.ticket) {
8989
9206
  emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
8990
9207
  return;
@@ -9077,14 +9294,14 @@ async function buildEnvironment(route, ship, ticket) {
9077
9294
  }
9078
9295
 
9079
9296
  // src/cli.ts
9080
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname9(process.execPath) : resolve4(dirname9(fileURLToPath(import.meta.url)), "..");
9297
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
9081
9298
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
9082
- var CYCLE_SECONDS = 120;
9299
+ var CYCLE_SECONDS2 = 120;
9083
9300
  var STALE_CYCLES = 3;
9084
9301
  function renderShipLine(s, issueLabelById) {
9085
9302
  const name = s.name ?? "(unnamed)";
9086
9303
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
9087
- const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS * 1e3;
9304
+ const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS2 * 1e3;
9088
9305
  const ageMs = Date.now() - Date.parse(s.last_seen);
9089
9306
  if (ageMs > staleAfterMs) {
9090
9307
  return `${name.padEnd(20)} not seen recently \u2014 last seen ${since(s.last_seen)} ago`;
@@ -9931,6 +10148,11 @@ ${changed2.map((c) => ` - ${c}
9931
10148
  const tracker = new Tracker(e.route, cfg.ship);
9932
10149
  await applyEpicSync(tracker, e.decision.epicSync, tracker.contract, remit);
9933
10150
  }
10151
+ for (const e of fleet.entries) {
10152
+ if (dryRun || e.error || !e.decision?.holdReleases?.length) continue;
10153
+ const tracker = new Tracker(e.route, cfg.ship);
10154
+ await applyHoldReleases(tracker, e.decision.holdReleases, tracker.contract, emit.forRoute(e.route.route));
10155
+ }
9934
10156
  for (const e of fleet.entries) {
9935
10157
  if (e.error || !e.decision || !e.decision.sweep.length) continue;
9936
10158
  const remit = emit.forRoute(e.route.route);
@@ -10022,7 +10244,8 @@ ${changed2.map((c) => ` - ${c}
10022
10244
  seat,
10023
10245
  contract.statuses.approved,
10024
10246
  contract.statuses.building,
10025
- (t) => dirForRepo(w.route, t.repo_id) !== null
10247
+ (t) => dirForRepo(w.route, t.repo_id) !== null,
10248
+ withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
10026
10249
  );
10027
10250
  if (result.contended.length) {
10028
10251
  wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10036,6 +10259,10 @@ ${changed2.map((c) => ` - ${c}
10036
10259
  { step: "select", role: w.role }
10037
10260
  );
10038
10261
  }
10262
+ if (result.held.length) {
10263
+ wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
10264
+ }
10265
+ await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
10039
10266
  if (!result.ticket) {
10040
10267
  wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10041
10268
  if (!skipInlineRelease) await releaseFleet();
@@ -10146,6 +10373,10 @@ ${changed2.map((c) => ` - ${c}
10146
10373
  await applyEpicSync(tracker, decision.epicSync, tracker.contract, emit);
10147
10374
  }
10148
10375
  }
10376
+ if (!dryRun && decision.holdReleases?.length) {
10377
+ const tracker = new Tracker(route, cfg.ship);
10378
+ await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
10379
+ }
10149
10380
  const role = value("role") ?? decision.selection.selected;
10150
10381
  if (command === "poll") break;
10151
10382
  if (!dryRun) {
@@ -10194,7 +10425,8 @@ ${changed2.map((c) => ` - ${c}
10194
10425
  seat,
10195
10426
  contract.statuses.approved,
10196
10427
  contract.statuses.building,
10197
- (t) => dirForRepo(route, t.repo_id) !== null
10428
+ (t) => dirForRepo(route, t.repo_id) !== null,
10429
+ withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
10198
10430
  );
10199
10431
  if (result.contended.length) {
10200
10432
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10208,6 +10440,10 @@ ${changed2.map((c) => ` - ${c}
10208
10440
  { step: "select", role: current }
10209
10441
  );
10210
10442
  }
10443
+ if (result.held.length) {
10444
+ emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
10445
+ }
10446
+ await noteReaccepted(tracker2, result, seat, cfg.ship.name);
10211
10447
  if (!result.ticket) {
10212
10448
  emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10213
10449
  dropLock();
@@ -10529,7 +10765,7 @@ ${changed2.map((c) => ` - ${c}
10529
10765
  const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10530
10766
  raw.agentPersonas = personaResult.agentPersonas;
10531
10767
  raw.agentSkills = skillResult.agentSkills;
10532
- mkdirSync10(dirOf(resolvedPath), { recursive: true });
10768
+ mkdirSync11(dirOf(resolvedPath), { recursive: true });
10533
10769
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
10534
10770
  `);
10535
10771
  }
@@ -10577,7 +10813,7 @@ ${changed2.map((c) => ` - ${c}
10577
10813
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
10578
10814
  const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10579
10815
  raw.agentSkills = result.agentSkills;
10580
- mkdirSync10(dirOf(resolvedPath), { recursive: true });
10816
+ mkdirSync11(dirOf(resolvedPath), { recursive: true });
10581
10817
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
10582
10818
  `);
10583
10819
  }
@@ -10621,7 +10857,7 @@ ${changed2.map((c) => ` - ${c}
10621
10857
  }
10622
10858
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
10623
10859
  const text = configText;
10624
- const base = dirname9(cfg.configFile);
10860
+ const base = dirname10(cfg.configFile);
10625
10861
  try {
10626
10862
  if (sub === "list") {
10627
10863
  const repos = configuredRepos(text, routeName, base);
@@ -10982,8 +11218,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10982
11218
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
10983
11219
  `);
10984
11220
  } else {
10985
- mkdirSync10(dirname9(resolvedPath), { recursive: true });
10986
- if (hadPreviousFile) copyFileSync(resolvedPath, `${resolvedPath}.bak`);
11221
+ mkdirSync11(dirname10(resolvedPath), { recursive: true });
11222
+ if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
10987
11223
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
10988
11224
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
10989
11225
  `);
@@ -10999,7 +11235,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10999
11235
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
11000
11236
  `);
11001
11237
  } else {
11002
- mkdirSync10(dirname9(apiKeyFile), { recursive: true });
11238
+ mkdirSync11(dirname10(apiKeyFile), { recursive: true });
11003
11239
  writeFileSync9(apiKeyFile, `${apiKeyVar}=${apiKey}
11004
11240
  `, { mode: 384 });
11005
11241
  process.stderr.write(`API key written to ${apiKeyFile}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.10.2",
3
+ "version": "0.12.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",
@@ -198,7 +198,7 @@ since your last comment: an answer to a question, new direction, or a
198
198
  the remote has moved), stop: comment on the ticket with what it printed and
199
199
  set `needs_info`; do not merge or rebase it yourself.
200
200
  - A ticket QA has bounced back to you comes in as `in_progress`,
201
- reassigned to your row, with a comment saying what still fails. Treat
201
+ reassigned to your row (or unassigned, when your ship holds it), with a comment saying what still fails. Treat
202
202
  that exactly like new direction from a hold: read the comment, fix what
203
203
  it names, and take it back to `fixed` when it's genuinely right. QA
204
204
  bouncing a ticket is the system working, not an accusation.
@@ -353,9 +353,12 @@ if `report_type` is either of those.
353
353
  reconcile it yourself. Post a comment on the ticket naming the repo and the
354
354
  counts `crew sync` printed, leave the ticket as you found it, and end the
355
355
  run — the operator reconciles the checkout. Otherwise, from
356
- the primary checkout, `git worktree add` a sibling worktree for this
357
- ticket, on a new branch cut from the base branch's current HEAD. The
358
- digest's `worktree` and `branch` columns give this ticket's own directory
356
+ the primary checkout, `git worktree add <dir> -b <branch> <remote>/<base>`
357
+ a sibling worktree for this ticket, on a new branch cut from the base
358
+ branch's REMOTE-TRACKING ref (`origin/main`, say), never from the primary
359
+ checkout's own local base — that copy is a cache of the remote, and a
360
+ local commit on it would be carried into every ticket cut from it
361
+ (CREW-1383). The digest's `worktree` and `branch` columns give this ticket's own directory
359
362
  and branch name — use them verbatim. The Environment section names the
360
363
  base branch and describes the general pattern, but its own worked example
361
364
  is illustrative only (rendered before any ticket is chosen, so it cannot
@@ -529,6 +532,10 @@ if `report_type` is either of those.
529
532
  part).
530
533
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
531
534
  and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
535
+ If the Issues table has a `held_by_ship_id` column, clear it in the same
536
+ write (the hold is for the building roles; QA on any ship may take a `fixed`
537
+ ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
538
+ when it sets `verified`.
532
539
  **Push the branch first, in the same step:**
533
540
  `git push --set-upstream <remote> <branch>` from the worktree (the remote is
534
541
  `origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
@@ -114,7 +114,11 @@ human review, so "probably fine" is not a pass.
114
114
  covered.
115
115
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
116
116
  lane's own Crew row id from the roster above — the dev seat if
117
- `needs_design` is false or null, the design seat if true. The comment
117
+ `needs_design` is false or null, the design seat if true. **Exception:** when the ticket carries a `held_by_ship_id`, leave that column
118
+ untouched and **clear** `assignee_id` instead of naming a seat — seats are
119
+ per ship, and the ship that holds the branch resumes an unassigned
120
+ `in_progress` ticket because it is the holder. Naming this ship's seat
121
+ would hand the bounce to a ship that never built it. The comment
118
122
  must be actionable:
119
123
  exactly what you did, what you expected, what happened, with a
120
124
  screenshot or the failing output. This includes a red suite, a
@@ -179,7 +179,7 @@ since your last comment: an answer to a question, new direction, or a
179
179
  the remote has moved), stop: comment on the ticket with what it printed and
180
180
  set `needs_info`; do not merge or rebase it yourself.
181
181
  - A ticket QA has bounced back to you comes in as `in_progress`,
182
- reassigned to your row, with a comment saying what still fails. Treat
182
+ reassigned to your row (or unassigned, when your ship holds it), with a comment saying what still fails. Treat
183
183
  that exactly like new direction from a hold: read the comment, fix what
184
184
  it names, and take it back to `fixed` when it's genuinely right. QA
185
185
  bouncing a ticket is the system working, not an accusation.
@@ -334,8 +334,12 @@ if `report_type` is either of those.
334
334
  reconcile it yourself. Post a comment on the ticket naming the repo and the
335
335
  counts `crew sync` printed, leave the ticket as you found it, and end the
336
336
  run — the operator reconciles the checkout. Otherwise, from
337
- the primary checkout, `git worktree add` a sibling worktree for this
338
- ticket, on a new branch cut from the base branch's current HEAD. The
337
+ the primary checkout, `git worktree add <dir> -b <branch> <remote>/<base>`
338
+ a sibling worktree for this ticket, on a new branch cut from the base
339
+ branch's REMOTE-TRACKING ref (`origin/main`, say), never from the primary
340
+ checkout's own local base — that copy is a cache of the remote, and a
341
+ local commit on it would be carried into every ticket cut from it
342
+ (CREW-1383). The
339
343
  digest's `worktree` and `branch` columns give this ticket's own directory
340
344
  and branch name — use them verbatim. The Environment section names the
341
345
  base branch and describes the general pattern, but its own worked example
@@ -510,6 +514,10 @@ if `report_type` is either of those.
510
514
  part).
511
515
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
512
516
  and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
517
+ If the Issues table has a `held_by_ship_id` column, clear it in the same
518
+ write (the hold is for the building roles; QA on any ship may take a `fixed`
519
+ ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
520
+ when it sets `verified`.
513
521
  **Push the branch first, in the same step:**
514
522
  `git push --set-upstream <remote> <branch>` from the worktree (the remote is
515
523
  `origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
@@ -104,7 +104,11 @@ human review, so "probably fine" is not a pass.
104
104
  the comment what you exercised, so the record shows what "verified"
105
105
  covered.
106
106
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
107
- seat's own Crew row id from the roster above. The comment must be
107
+ seat's own Crew row id from the roster above. **Exception:** when the ticket carries a `held_by_ship_id`, leave that column
108
+ untouched and **clear** `assignee_id` instead of naming a seat — seats are
109
+ per ship, and the ship that holds the branch resumes an unassigned
110
+ `in_progress` ticket because it is the holder. Naming this ship's seat
111
+ would hand the bounce to a ship that never built it. The comment must be
108
112
  actionable:
109
113
  exactly what you did, what you expected, what happened, with a
110
114
  screenshot or the failing output. This includes a red suite, a