@tablation/crew 0.49.3 → 0.49.5

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
@@ -4971,6 +4971,19 @@ var Tracker = class {
4971
4971
  limit: 500
4972
4972
  });
4973
4973
  }
4974
+ /**
4975
+ * Tickets at the hand-off status whose row changed at or after `since`,
4976
+ * other than `exceptId` (CREW-1759: a run's stray hand-offs).
4977
+ */
4978
+ async handedOffSince(since2, exceptId) {
4979
+ const c = this.contract;
4980
+ const rows = await this.client.records.list(this.models.issues, {
4981
+ filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
4982
+ limit: 100
4983
+ });
4984
+ const from = Date.parse(since2);
4985
+ return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
4986
+ }
4974
4987
  /**
4975
4988
  * The tickets behind a set of ticket numbers — the cleanup sweeps' input
4976
4989
  * (CREW-1593).
@@ -6099,6 +6112,7 @@ function operatorTodo(tickets, contract, operatorId) {
6099
6112
  }
6100
6113
 
6101
6114
  // src/digest.ts
6115
+ var DIRECTION_EVENT_TYPES = ["conflict_handed_back", "requeued", "bounced", "conflict_escalated"];
6102
6116
  function displayKey2(t) {
6103
6117
  return t.issue_tag ?? t.issue_id ?? "?";
6104
6118
  }
@@ -6133,10 +6147,24 @@ function lastComment(i, ticketId) {
6133
6147
  if (!last) return "\u2014";
6134
6148
  return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
6135
6149
  }
6150
+ var eventAt = (e) => e.occurred_at ?? e.created_at ?? "";
6151
+ function pendingDirection(i, ticketId) {
6152
+ const mine = (i.events ?? []).filter((e) => e.record_id === ticketId);
6153
+ const pushed = mine.filter((e) => e.event_type === "pushed").map(eventAt).sort().pop() ?? "";
6154
+ return mine.filter((e) => DIRECTION_EVENT_TYPES.includes(e.event_type) && eventAt(e) > pushed).sort((a, b) => eventAt(a) < eventAt(b) ? 1 : -1)[0];
6155
+ }
6156
+ function directionCell(i, ticketId) {
6157
+ const d = pendingDirection(i, ticketId);
6158
+ if (!d) return "\u2014";
6159
+ const msg = (d.message ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
6160
+ return `${d.event_type} ${eventAt(d).slice(0, 16)}Z${msg ? ` \u2014 ${msg}` : ""}`.replace(/\|/g, "/");
6161
+ }
6136
6162
  function newFromOthers(i, ticketId) {
6137
- return i.comments.filter(
6163
+ const comments = i.comments.filter(
6138
6164
  (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark
6139
6165
  ).length;
6166
+ const d = pendingDirection(i, ticketId);
6167
+ return comments + (d && eventAt(d) > i.watermark ? 1 : 0);
6140
6168
  }
6141
6169
  function who(t, i) {
6142
6170
  return whoAssigned(t, i) + heldBySuffix(t, i);
@@ -6175,14 +6203,14 @@ ${sorted(ts).map(row).join("\n")}
6175
6203
  `;
6176
6204
  }
6177
6205
  function buildingDigest(i) {
6178
- const header = "| ticket | repo | branch | worktree | mode | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
6206
+ const header = "| ticket | repo | branch | worktree | mode | status | assignee | filed by | sev | pri | eff | updated | last comment | runner direction | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
6179
6207
  const row = (t) => {
6180
6208
  const n = newFromOthers(i, t.id);
6181
6209
  const branch = i.branchFor ? i.branchFor(t) : "";
6182
6210
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
6183
6211
  const dir = i.dirFor ? i.dirFor(t) : null;
6184
6212
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
6185
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
6213
+ return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${directionCell(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
6186
6214
  };
6187
6215
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
6188
6216
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -6213,6 +6241,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
6213
6241
  "## Current queue \u2014 built for you by the poll\n",
6214
6242
  "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
6215
6243
  '\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',
6244
+ '\n"runner direction" is the newest instruction the crew itself posted to the\nevent log (not Comments) that no push has answered yet: `conflict_handed_back`\n(merge the base into the branch and resolve), `requeued`, `bounced`,\n`conflict_escalated`. It is direction, same as a comment from a hold: act on it.\n',
6216
6245
  "\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
6217
6246
  ...i.workModes && [...i.workModes.modes.values()].some((m) => m !== "automatic") ? ["\n**Manual / hybrid projects.** Some projects on this board are not fully automatic. In a `manual` project no lane touches any ticket, and a ticket with no project is treated the same way, so none appear below. In a `hybrid` project a ticket appears below only when it is assigned to an agent. An `accepted` ticket that is absent for either reason is not yours to claim \u2014 do not go looking for it.\n"] : [],
6218
6247
  "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
@@ -6436,6 +6465,7 @@ async function decideCycle(o) {
6436
6465
  tracker.projectWorkModes(),
6437
6466
  tracker.events("stalled")
6438
6467
  ]);
6468
+ const directionEvents = (await Promise.all([...DIRECTION_EVENT_TYPES, "pushed"].map((t) => tracker.events(t)))).flat();
6439
6469
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
6440
6470
  data: { tickets: tickets.length, comments: comments.length }
6441
6471
  });
@@ -6581,6 +6611,7 @@ async function decideCycle(o) {
6581
6611
  selection,
6582
6612
  actionable,
6583
6613
  watermark,
6614
+ directionEvents,
6584
6615
  selectionInput,
6585
6616
  attention: attentionCurrent
6586
6617
  };
@@ -6660,6 +6691,7 @@ function writeDigest(o, d, role, stateDir) {
6660
6691
  ships: d.ships,
6661
6692
  myShipId: d.ships.find((s) => (s.name ?? "").trim() === o.ship.name.trim())?.id ?? null,
6662
6693
  watermark: d.watermark,
6694
+ events: d.directionEvents,
6663
6695
  blocked: d.blocked,
6664
6696
  blockerInfo: d.info
6665
6697
  };
@@ -6764,7 +6796,14 @@ function worktreeForBranch(cwd, branch) {
6764
6796
  }
6765
6797
  return null;
6766
6798
  }
6767
- var priorBounces = (ticket, events) => events.filter((e) => e.record_id === ticket.id && e.event_type === "conflict_handed_back").length;
6799
+ var stampOf = (e) => e.occurred_at ?? e.created_at ?? "";
6800
+ var priorBounces = (ticket, events) => {
6801
+ const mine = events.filter((e) => e.record_id === ticket.id);
6802
+ const pushes = mine.filter((e) => e.event_type === "pushed").map(stampOf);
6803
+ return mine.filter(
6804
+ (e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))
6805
+ ).length;
6806
+ };
6768
6807
  function planConflictBounce(cwd, ticket, branch, base, paths, events) {
6769
6808
  return {
6770
6809
  ticket,
@@ -8738,21 +8777,15 @@ async function enforceHandoff(i) {
8738
8777
  return { kind: "failed", why: e.message };
8739
8778
  }
8740
8779
  }
8741
- async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
8780
+ async function guardRunHandoff(route, ship, tracker, role, workingId, log, since2) {
8742
8781
  if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
8743
8782
  const seatId = route.resolved?.seats[role];
8744
8783
  if (!seatId) return null;
8745
8784
  try {
8746
8785
  const t = await tracker.ticket(workingId);
8747
- const target = repoTargetFor(route, t.repo_id);
8748
- if (!target) return null;
8749
- const o = route.repoOverrides[target.name];
8750
- const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
8751
- hooks: { ...route.hooks, ...o?.hooks },
8752
- labels: { ...route.labels, ...o?.labels },
8753
- branch: { ...route.branch, ...o?.branch },
8754
- worktrees: { nameOverride: o?.worktreeName }
8755
- }, target.dir);
8786
+ const resolved = repoConfigFor(route, t);
8787
+ if (!resolved) return null;
8788
+ const { target, cfg } = resolved;
8756
8789
  if (role === "qa") {
8757
8790
  await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
8758
8791
  return null;
@@ -8771,6 +8804,7 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
8771
8804
  log
8772
8805
  });
8773
8806
  if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
8807
+ if (since2) await guardRunStrays(route, ship, tracker, role, workingId, since2, log);
8774
8808
  return outcome;
8775
8809
  } catch (e) {
8776
8810
  log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
@@ -8793,6 +8827,88 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
8793
8827
  log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
8794
8828
  }
8795
8829
  }
8830
+ function repoConfigFor(route, t) {
8831
+ const target = repoTargetFor(route, t.repo_id);
8832
+ if (!target) return null;
8833
+ const o = route.repoOverrides[target.name];
8834
+ const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
8835
+ hooks: { ...route.hooks, ...o?.hooks },
8836
+ labels: { ...route.labels, ...o?.labels },
8837
+ branch: { ...route.branch, ...o?.branch },
8838
+ worktrees: { nameOverride: o?.worktreeName }
8839
+ }, target.dir);
8840
+ return { target, cfg };
8841
+ }
8842
+ var STRAY_HANDOFF_MARKER = "<!-- crew:stray-handoff -->";
8843
+ async function guardStrayHandoffs(i) {
8844
+ const st = i.contract.statuses;
8845
+ if (i.role === "qa") return;
8846
+ for (const t of i.candidates) {
8847
+ const at = { ticket: t.issue_id, step: "handoff" };
8848
+ try {
8849
+ const holder = i.myShipId ? ticketHolderShipId(t, i.contract) : null;
8850
+ if (holder && holder !== i.myShipId) {
8851
+ await i.writer.updateTicket(t.id, { status: st.building });
8852
+ await i.writer.postEvent(
8853
+ t.id,
8854
+ `${STRAY_HANDOFF_MARKER}
8855
+ **Hand-off bounced: this ticket is held by another ship.**
8856
+
8857
+ ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
8858
+ i.seatId,
8859
+ "bounced"
8860
+ ).catch(() => i.log.warn("bounced, but the note failed to post", at));
8861
+ i.log.warn(`${t.issue_id} was handed off by a run that did not claim it, while held by another ship \u2014 bounced to ${st.building}`, at);
8862
+ continue;
8863
+ }
8864
+ const co = i.checkout(t);
8865
+ if (!co || !existingBranchForTicket(co.dir, co.cfg, t, i.role)) {
8866
+ i.log.warn(`${t.issue_id} moved to ${st.handoff} during a run that did not claim it; no local branch to verify, left alone`, at);
8867
+ continue;
8868
+ }
8869
+ const v = inspectHandoff(co.dir, co.cfg, t, i.role);
8870
+ i.log.warn(`${t.issue_id} was handed off by a run that did not claim it`, at);
8871
+ if (v.kind === "pushed") {
8872
+ await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
8873
+ } else if (v.kind === "bounce") {
8874
+ if (v.origin && v.local && !isAncestor(co.dir, v.origin, v.local)) {
8875
+ i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
8876
+ continue;
8877
+ }
8878
+ await i.writer.updateTicket(t.id, { status: st.building, ...t.assignee_id ? {} : { assignee_id: i.seatId } });
8879
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
8880
+ });
8881
+ }
8882
+ } catch (e) {
8883
+ i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
8884
+ }
8885
+ }
8886
+ }
8887
+ async function guardRunStrays(route, ship, tracker, role, workingId, since2, log) {
8888
+ const seatId = route.resolved?.seats[role];
8889
+ if (!seatId) return;
8890
+ try {
8891
+ const affinity = await tracker.claimAffinity();
8892
+ const candidates = await tracker.handedOffSince(since2, workingId);
8893
+ if (candidates.length === 0) return;
8894
+ await guardStrayHandoffs({
8895
+ writer: tracker,
8896
+ contract: tracker.contract,
8897
+ candidates,
8898
+ seatId,
8899
+ role,
8900
+ myShipId: affinity?.shipId ?? null,
8901
+ shipName: ship.name,
8902
+ checkout: (t) => {
8903
+ const r = repoConfigFor(route, t);
8904
+ return r ? { dir: r.target.dir, cfg: r.cfg } : null;
8905
+ },
8906
+ log
8907
+ });
8908
+ } catch (e) {
8909
+ log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
8910
+ }
8911
+ }
8796
8912
 
8797
8913
  // src/connect-wizard.ts
8798
8914
  import { delimiter, dirname as dirname8, join as join17 } from "path";
@@ -12743,8 +12859,9 @@ async function runRoleAgent(role, decision, ctx) {
12743
12859
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
12744
12860
  }
12745
12861
  try {
12862
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
12746
12863
  await spawnAgent(plan, emit);
12747
- await guardRunHandoff(route, ship, tracker, role, workingId, emit);
12864
+ await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
12748
12865
  } finally {
12749
12866
  if (memberId) {
12750
12867
  try {
@@ -13731,7 +13848,7 @@ ${changed2.map((c) => ` - ${c}
13731
13848
  }
13732
13849
  }
13733
13850
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
13734
- const handedBack = await tracker.events("conflict_handed_back");
13851
+ const handedBack = [...await tracker.events("conflict_handed_back"), ...await tracker.events("pushed")];
13735
13852
  const flagged = await tracker.events("stranded_flagged");
13736
13853
  const seat = releaseSeat(c);
13737
13854
  for (const f of outcome.conflicts ?? []) {
@@ -14019,8 +14136,9 @@ ${changed2.map((c) => ` - ${c}
14019
14136
  wemit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
14020
14137
  }
14021
14138
  try {
14139
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
14022
14140
  await spawnAgent(fleetPlan, wemit);
14023
- await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
14141
+ await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
14024
14142
  } finally {
14025
14143
  dropLock();
14026
14144
  if (fleetMemberId) {
@@ -14234,8 +14352,9 @@ ${changed2.map((c) => ` - ${c}
14234
14352
  emit.warn(`could not beat ship engaged: ${e.message}`, { step: "agent" });
14235
14353
  }
14236
14354
  try {
14355
+ const runStart = (/* @__PURE__ */ new Date()).toISOString();
14237
14356
  await spawnAgent(plan, emit);
14238
- await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
14357
+ await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
14239
14358
  } finally {
14240
14359
  dropLock();
14241
14360
  if (memberId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.49.3",
3
+ "version": "0.49.5",
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",
@@ -141,6 +141,12 @@ Then:
141
141
  the ticket again because the status was left alone.
142
142
  - A hold gave new direction on an `in_progress` ticket → adjust the branch
143
143
  and comment on what changed.
144
+ - A ticket whose `runner direction` cell is not `—` has been handed back by
145
+ the crew itself (a conflict with the base, a requeue, a bounce). That is
146
+ direction exactly as a comment from a hold is, and "no new comment" does not
147
+ cancel it: for `conflict_handed_back`, merge `<remote>/<base>` into the
148
+ branch, resolve, run the suite, push as a fast-forward and set `fixed`
149
+ (Step 4.4). Never answer it with "nothing to build".
144
150
  - An `in_progress` ticket with no assignee and no new comment → still "up
145
151
  for grabs, more work to do": a hold clearing the assignee *is* the signal.
146
152
  - **An `in_progress` ticket assigned to you with no new comment is your own
@@ -397,8 +403,10 @@ worktree, runs the test gate and deploys. Three consequences:
397
403
  lines from Step 3.5 are the only input the release phase has; nobody
398
404
  reads the diff later to fill them in.
399
405
  3. **A branch that no longer squash-merges cleanly comes back to you** at
400
- `in_progress`, reassigned to your lane, with a comment naming the
401
- conflict. Fix it in the same worktree and take it to `fixed` again. "Never
406
+ `in_progress`, reassigned to your lane, with the hand-back
407
+ posted to the event log, which the digest's `runner direction` column
408
+ shows (a ticket there reads as new direction even with no new comment).
409
+ Fix it in the same worktree and take it to `fixed` again. "Never
402
410
  merge" above means never merge INTO the base; merging the base INTO your
403
411
  own ticket branch is the expected fix (rule 4), and it is not "by hand to
404
412
  help".
@@ -478,6 +486,12 @@ existing worktree and note that the status change was not needed.
478
486
 
479
487
  - One ticket at a time. Never mix two tickets' changes in one branch,
480
488
  worktree or commit.
489
+ - **A ticket that appears mid-run is not yours this run.** A bounce, a
490
+ re-accept or a hand-back that lands while you work another ticket is left for
491
+ a later run, which claims it with `assignee_id` and the ship hold like any
492
+ other. Finish the ticket you claimed. Before touching any worktree, re-fetch
493
+ its record and stop if the ship hold names another ship. Setting `fixed` on a
494
+ ticket you did not claim is bounced by the runner.
481
495
  - **Stay in your lane.** Status decides QA's slice; `needs_design` decides
482
496
  dev versus design. Both are checked before assignee and before whether a
483
497
  worktree exists.
@@ -142,6 +142,12 @@ Then:
142
142
  the ticket again because the status was left alone.
143
143
  - A hold gave new direction on an `in_progress` ticket → adjust the branch
144
144
  and comment on what changed.
145
+ - A ticket whose `runner direction` cell is not `—` has been handed back by
146
+ the crew itself (a conflict with the base, a requeue, a bounce). That is
147
+ direction exactly as a comment from a hold is, and "no new comment" does not
148
+ cancel it: for `conflict_handed_back`, merge `<remote>/<base>` into the
149
+ branch, resolve, run the suite, push as a fast-forward and set `fixed`
150
+ (Step 4.4). Never answer it with "nothing to build".
145
151
  - An `in_progress` ticket with no assignee and no new comment → still "up
146
152
  for grabs, more work to do": a hold clearing the assignee *is* the signal.
147
153
  - **An `in_progress` ticket assigned to you with no new comment is your own
@@ -397,8 +403,10 @@ worktree, runs the test gate and deploys. Three consequences:
397
403
  lines from Step 3.5 are the only input the release phase has; nobody
398
404
  reads the diff later to fill them in.
399
405
  3. **A branch that no longer squash-merges cleanly comes back to you** at
400
- `in_progress`, reassigned to your lane, with a comment naming the
401
- conflict. Fix it in the same worktree and take it to `fixed` again. "Never
406
+ `in_progress`, reassigned to your lane, with the hand-back
407
+ posted to the event log, which the digest's `runner direction` column
408
+ shows (a ticket there reads as new direction even with no new comment).
409
+ Fix it in the same worktree and take it to `fixed` again. "Never
402
410
  merge" above means never merge INTO the base; merging the base INTO your
403
411
  own ticket branch is the expected fix (rule 4), and it is not "by hand to
404
412
  help".
@@ -478,6 +486,12 @@ existing worktree and note that the status change was not needed.
478
486
 
479
487
  - One ticket at a time. Never mix two tickets' changes in one branch,
480
488
  worktree or commit.
489
+ - **A ticket that appears mid-run is not yours this run.** A bounce, a
490
+ re-accept or a hand-back that lands while you work another ticket is left for
491
+ a later run, which claims it with `assignee_id` and the ship hold like any
492
+ other. Finish the ticket you claimed. Before touching any worktree, re-fetch
493
+ its record and stop if the ship hold names another ship. Setting `fixed` on a
494
+ ticket you did not claim is bounced by the runner.
481
495
  - **Stay in your lane.** Status decides QA's slice (`fixed` and `qa`);
482
496
  everything else is dev's. Checked before assignee and before whether a
483
497
  worktree exists.