@tablation/crew 0.11.0 → 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
@@ -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
  }
@@ -4220,6 +4304,7 @@ async function decideCycle(o) {
4220
4304
  emit.warn(`ticket(s) blocking themselves, parked permanently: ${diag.selfBlocked.join(", ")}`);
4221
4305
  }
4222
4306
  const sweep = planSweep(tickets, info, blocked);
4307
+ const holdReleases = planHoldReleases(tickets, tracker.contract);
4223
4308
  const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
4224
4309
  emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
4225
4310
  const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
@@ -4287,7 +4372,8 @@ async function decideCycle(o) {
4287
4372
  holds: holds2,
4288
4373
  seats: route.resolved.seats,
4289
4374
  paused: state.pausedRoles(["dev", "design", "qa"]),
4290
- contract: tracker.contract
4375
+ contract: tracker.contract,
4376
+ ships: { myShipId: shipIdByName(ships, ship.name), rows: ships }
4291
4377
  };
4292
4378
  const selection = selectRole(selectionInput);
4293
4379
  const actionable = actionableSummary(selectionInput, selection.pending);
@@ -4300,6 +4386,7 @@ async function decideCycle(o) {
4300
4386
  info,
4301
4387
  sweep,
4302
4388
  epicSync,
4389
+ holdReleases,
4303
4390
  stranded,
4304
4391
  selection,
4305
4392
  actionable,
@@ -4400,22 +4487,62 @@ function rosterFor(d, route, role) {
4400
4487
 
4401
4488
  // src/claim.ts
4402
4489
  import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
4403
- 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) {
4404
4511
  const contended = [];
4405
4512
  const unservable = [];
4513
+ const held = [];
4406
4514
  for (const t of candidates) {
4515
+ if (affinity?.isHeldElsewhere?.(t)) {
4516
+ held.push(t.issue_id);
4517
+ continue;
4518
+ }
4407
4519
  if (isServable && !isServable(t)) {
4408
4520
  unservable.push(t.issue_id);
4409
4521
  continue;
4410
4522
  }
4411
- 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
+ }
4412
4534
  try {
4413
4535
  const claimed = await tracker.updateTicket(
4414
4536
  t.id,
4415
- { status: buildingStatus, assignee_id: seat },
4537
+ {
4538
+ status: buildingStatus,
4539
+ assignee_id: seat,
4540
+ ...affinity ? { [affinity.column]: affinity.shipId } : {}
4541
+ },
4416
4542
  t.updated_at
4417
4543
  );
4418
- 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 };
4419
4546
  } catch (e) {
4420
4547
  if (e instanceof StaleWriteError4) {
4421
4548
  contended.push(t.issue_id);
@@ -4424,7 +4551,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4424
4551
  throw e;
4425
4552
  }
4426
4553
  }
4427
- return { ticket: null, claimed: false, contended, unservable };
4554
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
4428
4555
  }
4429
4556
 
4430
4557
  // src/conflict.ts
@@ -8976,6 +9103,10 @@ async function runOnePass(o) {
8976
9103
  emit.warn(`could not sweep: ${e.message}`, { step: "sweep" });
8977
9104
  }
8978
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
+ }
8979
9110
  const order = [...decision.selection.pending].sort(
8980
9111
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
8981
9112
  );
@@ -9052,7 +9183,8 @@ async function runRoleAgent(role, decision, ctx) {
9052
9183
  seat,
9053
9184
  contract.statuses.approved,
9054
9185
  contract.statuses.building,
9055
- (t) => dirForRepo(route, t.repo_id) !== null
9186
+ (t) => dirForRepo(route, t.repo_id) !== null,
9187
+ withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
9056
9188
  );
9057
9189
  if (result.contended.length) {
9058
9190
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -9066,6 +9198,10 @@ async function runRoleAgent(role, decision, ctx) {
9066
9198
  role
9067
9199
  });
9068
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);
9069
9205
  if (!result.ticket) {
9070
9206
  emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
9071
9207
  return;
@@ -9160,12 +9296,12 @@ async function buildEnvironment(route, ship, ticket) {
9160
9296
  // src/cli.ts
9161
9297
  var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
9162
9298
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
9163
- var CYCLE_SECONDS = 120;
9299
+ var CYCLE_SECONDS2 = 120;
9164
9300
  var STALE_CYCLES = 3;
9165
9301
  function renderShipLine(s, issueLabelById) {
9166
9302
  const name = s.name ?? "(unnamed)";
9167
9303
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
9168
- const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS * 1e3;
9304
+ const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS2 * 1e3;
9169
9305
  const ageMs = Date.now() - Date.parse(s.last_seen);
9170
9306
  if (ageMs > staleAfterMs) {
9171
9307
  return `${name.padEnd(20)} not seen recently \u2014 last seen ${since(s.last_seen)} ago`;
@@ -10012,6 +10148,11 @@ ${changed2.map((c) => ` - ${c}
10012
10148
  const tracker = new Tracker(e.route, cfg.ship);
10013
10149
  await applyEpicSync(tracker, e.decision.epicSync, tracker.contract, remit);
10014
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
+ }
10015
10156
  for (const e of fleet.entries) {
10016
10157
  if (e.error || !e.decision || !e.decision.sweep.length) continue;
10017
10158
  const remit = emit.forRoute(e.route.route);
@@ -10103,7 +10244,8 @@ ${changed2.map((c) => ` - ${c}
10103
10244
  seat,
10104
10245
  contract.statuses.approved,
10105
10246
  contract.statuses.building,
10106
- (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)
10107
10249
  );
10108
10250
  if (result.contended.length) {
10109
10251
  wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10117,6 +10259,10 @@ ${changed2.map((c) => ` - ${c}
10117
10259
  { step: "select", role: w.role }
10118
10260
  );
10119
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);
10120
10266
  if (!result.ticket) {
10121
10267
  wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10122
10268
  if (!skipInlineRelease) await releaseFleet();
@@ -10227,6 +10373,10 @@ ${changed2.map((c) => ` - ${c}
10227
10373
  await applyEpicSync(tracker, decision.epicSync, tracker.contract, emit);
10228
10374
  }
10229
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
+ }
10230
10380
  const role = value("role") ?? decision.selection.selected;
10231
10381
  if (command === "poll") break;
10232
10382
  if (!dryRun) {
@@ -10275,7 +10425,8 @@ ${changed2.map((c) => ` - ${c}
10275
10425
  seat,
10276
10426
  contract.statuses.approved,
10277
10427
  contract.statuses.building,
10278
- (t) => dirForRepo(route, t.repo_id) !== null
10428
+ (t) => dirForRepo(route, t.repo_id) !== null,
10429
+ withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
10279
10430
  );
10280
10431
  if (result.contended.length) {
10281
10432
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10289,6 +10440,10 @@ ${changed2.map((c) => ` - ${c}
10289
10440
  { step: "select", role: current }
10290
10441
  );
10291
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);
10292
10447
  if (!result.ticket) {
10293
10448
  emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10294
10449
  dropLock();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.11.0",
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.
@@ -532,6 +532,10 @@ if `report_type` is either of those.
532
532
  part).
533
533
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
534
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`.
535
539
  **Push the branch first, in the same step:**
536
540
  `git push --set-upstream <remote> <branch>` from the worktree (the remote is
537
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.
@@ -514,6 +514,10 @@ if `report_type` is either of those.
514
514
  part).
515
515
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
516
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`.
517
521
  **Push the branch first, in the same step:**
518
522
  `git push --set-upstream <remote> <branch>` from the worktree (the remote is
519
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