@tablation/crew 0.11.0 → 0.12.1

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,63 @@ 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
+ const statuses = a.contract.statuses;
3367
+ if ([statuses.handoff, statuses.verifying].includes(t.status)) return false;
3368
+ return heldByLiveOtherShip(t, a);
3369
+ }
3370
+ function heldByLiveOtherShip(t, a) {
3371
+ if (!a.myShipId) return false;
3372
+ const holder = ticketHolderShipId(t, a.contract);
3373
+ if (!holder || holder === a.myShipId) return false;
3374
+ const owner = a.ships.find((s) => s.id === holder);
3375
+ return shipIsAlive(owner, a.now ?? Date.now());
3376
+ }
3377
+ function shipIdByName(ships, name) {
3378
+ const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
3379
+ return mine.length === 1 ? mine[0].id : null;
3380
+ }
3381
+ async function noteReaccepted(tracker, result, seat, shipLabel) {
3382
+ if (!result.reaccepted || !result.ticket) return;
3383
+ try {
3384
+ await tracker.postEvent(
3385
+ result.ticket.id,
3386
+ `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\`.`,
3387
+ seat
3388
+ );
3389
+ } catch {
3390
+ }
3391
+ }
3392
+ function planHoldReleases(tickets, contract) {
3393
+ const ends = [contract.statuses.handoff, contract.statuses.verified];
3394
+ return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
3395
+ }
3396
+ async function applyHoldReleases(writer, tickets, contract, emit) {
3397
+ let cleared = 0;
3398
+ for (const t of tickets) {
3399
+ try {
3400
+ await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
3401
+ cleared++;
3402
+ emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
3403
+ } catch (e) {
3404
+ emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
3405
+ }
3406
+ }
3407
+ return cleared;
3408
+ }
3409
+
3336
3410
  // src/select.ts
3337
3411
  var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
3338
3412
  var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
@@ -3344,10 +3418,23 @@ function sliceFor(tickets, role) {
3344
3418
  return buildingSlice(tickets, role);
3345
3419
  }
3346
3420
  var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
3421
+ function affinityContext(i) {
3422
+ return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
3423
+ }
3424
+ function withHoldCheck(affinity, i) {
3425
+ if (!affinity) return void 0;
3426
+ const a = affinityContext(i);
3427
+ return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
3428
+ }
3429
+ function notHeldByOtherShip(slice, i) {
3430
+ if (!i.ships) return slice;
3431
+ const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
3432
+ return slice.filter((t) => !heldByOtherShip(t, a));
3433
+ }
3347
3434
  function buildingRoleHasWork(role, i) {
3348
3435
  const me = i.seats[role];
3349
3436
  if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
3350
- const mine = sliceFor(i.tickets, role);
3437
+ const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
3351
3438
  const startable = mine.filter(
3352
3439
  (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
3353
3440
  );
@@ -3397,7 +3484,7 @@ function roleCandidates(role, i) {
3397
3484
  if (role === "pair") return [];
3398
3485
  const me = i.seats[role];
3399
3486
  if (!me) return [];
3400
- return sliceFor(i.tickets, role).filter(
3487
+ return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
3401
3488
  (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
3489
  );
3403
3490
  }
@@ -4220,6 +4307,7 @@ async function decideCycle(o) {
4220
4307
  emit.warn(`ticket(s) blocking themselves, parked permanently: ${diag.selfBlocked.join(", ")}`);
4221
4308
  }
4222
4309
  const sweep = planSweep(tickets, info, blocked);
4310
+ const holdReleases = planHoldReleases(tickets, tracker.contract);
4223
4311
  const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
4224
4312
  emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
4225
4313
  const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
@@ -4287,7 +4375,8 @@ async function decideCycle(o) {
4287
4375
  holds: holds2,
4288
4376
  seats: route.resolved.seats,
4289
4377
  paused: state.pausedRoles(["dev", "design", "qa"]),
4290
- contract: tracker.contract
4378
+ contract: tracker.contract,
4379
+ ships: { myShipId: shipIdByName(ships, ship.name), rows: ships }
4291
4380
  };
4292
4381
  const selection = selectRole(selectionInput);
4293
4382
  const actionable = actionableSummary(selectionInput, selection.pending);
@@ -4300,6 +4389,7 @@ async function decideCycle(o) {
4300
4389
  info,
4301
4390
  sweep,
4302
4391
  epicSync,
4392
+ holdReleases,
4303
4393
  stranded,
4304
4394
  selection,
4305
4395
  actionable,
@@ -4400,22 +4490,65 @@ function rosterFor(d, route, role) {
4400
4490
 
4401
4491
  // src/claim.ts
4402
4492
  import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
4403
- async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable) {
4493
+ async function stampResumption(tracker, t, affinity) {
4494
+ let current = t;
4495
+ for (let attempt = 0; attempt < 2; attempt++) {
4496
+ try {
4497
+ const stamped = await tracker.updateTicket(current.id, { [affinity.column]: affinity.shipId }, current.updated_at);
4498
+ return { outcome: "stamped", ticket: { ...current, ...stamped } };
4499
+ } catch (e) {
4500
+ if (!(e instanceof StaleWriteError4)) throw e;
4501
+ }
4502
+ if (!tracker.ticket) return { outcome: "unknown" };
4503
+ try {
4504
+ current = await tracker.ticket(t.id);
4505
+ } catch {
4506
+ return { outcome: "unknown" };
4507
+ }
4508
+ if ((affinity.isLiveHoldElsewhere ?? affinity.isHeldElsewhere)?.(current)) return { outcome: "held" };
4509
+ if (current[affinity.column] === affinity.shipId) return { outcome: "stamped", ticket: current };
4510
+ }
4511
+ return { outcome: "unknown" };
4512
+ }
4513
+ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, buildingStatus, isServable, affinity) {
4404
4514
  const contended = [];
4405
4515
  const unservable = [];
4516
+ const held = [];
4406
4517
  for (const t of candidates) {
4518
+ if (affinity?.isHeldElsewhere?.(t)) {
4519
+ held.push(t.issue_id);
4520
+ continue;
4521
+ }
4407
4522
  if (isServable && !isServable(t)) {
4408
4523
  unservable.push(t.issue_id);
4409
4524
  continue;
4410
4525
  }
4411
- if (t.status !== approvedStatus) return { ticket: t, claimed: false, contended, unservable };
4526
+ if (t.status !== approvedStatus) {
4527
+ if (affinity?.isLiveHoldElsewhere?.(t)) {
4528
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
4529
+ }
4530
+ if (affinity && t[affinity.column] !== affinity.shipId) {
4531
+ const stamp = await stampResumption(tracker, t, affinity);
4532
+ if (stamp.outcome === "stamped") {
4533
+ return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
4534
+ }
4535
+ (stamp.outcome === "held" ? held : contended).push(t.issue_id);
4536
+ continue;
4537
+ }
4538
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
4539
+ }
4412
4540
  try {
4413
4541
  const claimed = await tracker.updateTicket(
4414
4542
  t.id,
4415
- { status: buildingStatus, assignee_id: seat },
4543
+ {
4544
+ status: buildingStatus,
4545
+ assignee_id: seat,
4546
+ ...affinity ? { [affinity.column]: affinity.shipId } : {}
4547
+ },
4416
4548
  t.updated_at
4417
4549
  );
4418
- return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable };
4550
+ const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
4551
+ return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
4419
4552
  } catch (e) {
4420
4553
  if (e instanceof StaleWriteError4) {
4421
4554
  contended.push(t.issue_id);
@@ -4424,7 +4557,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4424
4557
  throw e;
4425
4558
  }
4426
4559
  }
4427
- return { ticket: null, claimed: false, contended, unservable };
4560
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
4428
4561
  }
4429
4562
 
4430
4563
  // src/conflict.ts
@@ -8976,6 +9109,10 @@ async function runOnePass(o) {
8976
9109
  emit.warn(`could not sweep: ${e.message}`, { step: "sweep" });
8977
9110
  }
8978
9111
  }
9112
+ if (decision.holdReleases?.length) {
9113
+ const tracker = new Tracker(o.route, o.ship);
9114
+ await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
9115
+ }
8979
9116
  const order = [...decision.selection.pending].sort(
8980
9117
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
8981
9118
  );
@@ -9052,7 +9189,8 @@ async function runRoleAgent(role, decision, ctx) {
9052
9189
  seat,
9053
9190
  contract.statuses.approved,
9054
9191
  contract.statuses.building,
9055
- (t) => dirForRepo(route, t.repo_id) !== null
9192
+ (t) => dirForRepo(route, t.repo_id) !== null,
9193
+ withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
9056
9194
  );
9057
9195
  if (result.contended.length) {
9058
9196
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -9066,6 +9204,10 @@ async function runRoleAgent(role, decision, ctx) {
9066
9204
  role
9067
9205
  });
9068
9206
  }
9207
+ if (result.held.length) {
9208
+ emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
9209
+ }
9210
+ await noteReaccepted(tracker, result, seat, ship.name);
9069
9211
  if (!result.ticket) {
9070
9212
  emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
9071
9213
  return;
@@ -9160,12 +9302,12 @@ async function buildEnvironment(route, ship, ticket) {
9160
9302
  // src/cli.ts
9161
9303
  var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
9162
9304
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
9163
- var CYCLE_SECONDS = 120;
9305
+ var CYCLE_SECONDS2 = 120;
9164
9306
  var STALE_CYCLES = 3;
9165
9307
  function renderShipLine(s, issueLabelById) {
9166
9308
  const name = s.name ?? "(unnamed)";
9167
9309
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
9168
- const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS * 1e3;
9310
+ const staleAfterMs = STALE_CYCLES * CYCLE_SECONDS2 * 1e3;
9169
9311
  const ageMs = Date.now() - Date.parse(s.last_seen);
9170
9312
  if (ageMs > staleAfterMs) {
9171
9313
  return `${name.padEnd(20)} not seen recently \u2014 last seen ${since(s.last_seen)} ago`;
@@ -10012,6 +10154,11 @@ ${changed2.map((c) => ` - ${c}
10012
10154
  const tracker = new Tracker(e.route, cfg.ship);
10013
10155
  await applyEpicSync(tracker, e.decision.epicSync, tracker.contract, remit);
10014
10156
  }
10157
+ for (const e of fleet.entries) {
10158
+ if (dryRun || e.error || !e.decision?.holdReleases?.length) continue;
10159
+ const tracker = new Tracker(e.route, cfg.ship);
10160
+ await applyHoldReleases(tracker, e.decision.holdReleases, tracker.contract, emit.forRoute(e.route.route));
10161
+ }
10015
10162
  for (const e of fleet.entries) {
10016
10163
  if (e.error || !e.decision || !e.decision.sweep.length) continue;
10017
10164
  const remit = emit.forRoute(e.route.route);
@@ -10103,7 +10250,8 @@ ${changed2.map((c) => ` - ${c}
10103
10250
  seat,
10104
10251
  contract.statuses.approved,
10105
10252
  contract.statuses.building,
10106
- (t) => dirForRepo(w.route, t.repo_id) !== null
10253
+ (t) => dirForRepo(w.route, t.repo_id) !== null,
10254
+ withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
10107
10255
  );
10108
10256
  if (result.contended.length) {
10109
10257
  wemit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10117,6 +10265,10 @@ ${changed2.map((c) => ` - ${c}
10117
10265
  { step: "select", role: w.role }
10118
10266
  );
10119
10267
  }
10268
+ if (result.held.length) {
10269
+ wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
10270
+ }
10271
+ await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
10120
10272
  if (!result.ticket) {
10121
10273
  wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10122
10274
  if (!skipInlineRelease) await releaseFleet();
@@ -10227,6 +10379,10 @@ ${changed2.map((c) => ` - ${c}
10227
10379
  await applyEpicSync(tracker, decision.epicSync, tracker.contract, emit);
10228
10380
  }
10229
10381
  }
10382
+ if (!dryRun && decision.holdReleases?.length) {
10383
+ const tracker = new Tracker(route, cfg.ship);
10384
+ await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
10385
+ }
10230
10386
  const role = value("role") ?? decision.selection.selected;
10231
10387
  if (command === "poll") break;
10232
10388
  if (!dryRun) {
@@ -10275,7 +10431,8 @@ ${changed2.map((c) => ` - ${c}
10275
10431
  seat,
10276
10432
  contract.statuses.approved,
10277
10433
  contract.statuses.building,
10278
- (t) => dirForRepo(route, t.repo_id) !== null
10434
+ (t) => dirForRepo(route, t.repo_id) !== null,
10435
+ withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
10279
10436
  );
10280
10437
  if (result.contended.length) {
10281
10438
  emit.emit(`claim contended for ${result.contended.join(", ")} \u2014 moved to the next candidate`, {
@@ -10289,6 +10446,10 @@ ${changed2.map((c) => ` - ${c}
10289
10446
  { step: "select", role: current }
10290
10447
  );
10291
10448
  }
10449
+ if (result.held.length) {
10450
+ emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
10451
+ }
10452
+ await noteReaccepted(tracker2, result, seat, cfg.ship.name);
10292
10453
  if (!result.ticket) {
10293
10454
  emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10294
10455
  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.1",
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