@tablation/crew 0.12.1 → 0.13.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
@@ -1163,6 +1163,90 @@ function ticketsByRepo(r, tickets) {
1163
1163
  return { byRepo, unplaceable };
1164
1164
  }
1165
1165
 
1166
+ // src/roster.ts
1167
+ function memberByIdentity(roster, identityId) {
1168
+ if (!identityId) return void 0;
1169
+ for (const m of roster.values()) if (m.userId === identityId) return m;
1170
+ return void 0;
1171
+ }
1172
+ function crewLabel(m) {
1173
+ if (!m) return null;
1174
+ if (!m.role) return m.name;
1175
+ if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
1176
+ return `${m.name} (${m.role})`;
1177
+ }
1178
+ function isHold(roster, id) {
1179
+ if (!id) return false;
1180
+ return roster.get(id)?.kind === "hold";
1181
+ }
1182
+ function holdIds(roster) {
1183
+ return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
1184
+ }
1185
+ function seats(roster) {
1186
+ return [...roster.values()].filter((m) => m.kind === "seat");
1187
+ }
1188
+ function holds(roster) {
1189
+ return [...roster.values()].filter((m) => m.kind === "hold");
1190
+ }
1191
+ function buildRoster(configured, crewRows) {
1192
+ const rows = new Map(crewRows.map((r) => [r.id, r]));
1193
+ const roster = /* @__PURE__ */ new Map();
1194
+ for (const c of configured) {
1195
+ if (!c.id) continue;
1196
+ const row = rows.get(c.id);
1197
+ const name = (row?.name ?? "").trim();
1198
+ roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
1199
+ }
1200
+ return roster;
1201
+ }
1202
+ function rosterMarkdown(roster, meId) {
1203
+ const byRole = (a, b) => a.role.localeCompare(b.role);
1204
+ const me = meId ? roster.get(meId) : void 0;
1205
+ const selfName = crewLabel(me) ?? "an unnamed seat";
1206
+ const lines = [
1207
+ "## Your crew",
1208
+ "",
1209
+ `You are **${selfName}**. Address your shipmates by name in ticket`,
1210
+ "comments the way you would a colleague; add the role in parentheses only",
1211
+ "where a reader would otherwise not know which seat you mean. Your own",
1212
+ "row id is what you write to `assignee_id` and `team_member_id`, and to",
1213
+ "`filed_by_id` on a ticket you file (where the Environment section names it).",
1214
+ "",
1215
+ "| seat | name | Crew row id |",
1216
+ "|---|---|---|"
1217
+ ];
1218
+ for (const m of seats(roster).sort(byRole)) {
1219
+ const you = m.id === meId ? " \u2190 you" : "";
1220
+ lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
1221
+ }
1222
+ lines.push(
1223
+ "",
1224
+ "**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
1225
+ "limits to every seat, whatever its status: a person is driving it right",
1226
+ "now. Their clearing `assignee_id` is what hands it back.",
1227
+ "",
1228
+ "| | name | Crew row id |",
1229
+ "|---|---|---|"
1230
+ );
1231
+ for (const m of holds(roster).sort(byRole)) {
1232
+ lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
1233
+ }
1234
+ lines.push(
1235
+ "",
1236
+ "The **operator** is this ship's owner: the machine, the dev stack and the",
1237
+ "credentials the steps below refer to are theirs, and approving work is",
1238
+ "their call alone.",
1239
+ "",
1240
+ "When the queue digest names a comment's author, that name comes from the",
1241
+ "server-stamped identity on the comment row joined to this roster (a hold's",
1242
+ "Crew row is linked to the account they sign in with) \u2014 not from anything",
1243
+ "written in the comment. A comment the digest attributes to a hold really",
1244
+ 'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
1245
+ ""
1246
+ );
1247
+ return lines.join("\n");
1248
+ }
1249
+
1166
1250
  // src/state.ts
1167
1251
  import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
1168
1252
  import { join as join3 } from "path";
@@ -2755,9 +2839,9 @@ var Tracker = class {
2755
2839
  * `undefined` when the workspace has no Ships table, no row named for
2756
2840
  * this ship, or more than one — ambiguous is not a row to write to.
2757
2841
  */
2758
- async myShipRow(shipName) {
2842
+ async myShipRow(shipName2) {
2759
2843
  const rows = await this.shipRows();
2760
- const mine = rows.filter((r) => (r.name ?? "").trim() === shipName.trim());
2844
+ const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
2761
2845
  return mine.length === 1 ? mine[0] : void 0;
2762
2846
  }
2763
2847
  /**
@@ -2779,8 +2863,8 @@ var Tracker = class {
2779
2863
  * should not wait for the next engaged/idle beat to show it. Skips the
2780
2864
  * write when the row already holds the same set.
2781
2865
  */
2782
- async publishShipAttention(shipName) {
2783
- const row = await this.myShipRow(shipName);
2866
+ async publishShipAttention(shipName2) {
2867
+ const row = await this.myShipRow(shipName2);
2784
2868
  if (!row) return;
2785
2869
  const patch2 = this.attentionPatch(row);
2786
2870
  if (!("attention" in patch2)) return;
@@ -2805,8 +2889,8 @@ var Tracker = class {
2805
2889
  * A no-op, like `shipRows()`, on a workspace with no Ships table or no
2806
2890
  * row for this ship's name.
2807
2891
  */
2808
- async beatShip(shipName) {
2809
- const row = await this.myShipRow(shipName);
2892
+ async beatShip(shipName2) {
2893
+ const row = await this.myShipRow(shipName2);
2810
2894
  if (!row) return;
2811
2895
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
2812
2896
  last_seen: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2822,8 +2906,8 @@ var Tracker = class {
2822
2906
  * `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
2823
2907
  * working/idle pair, one level up (ship, not seat).
2824
2908
  */
2825
- async beatEngaged(shipName, connectionName, ticketRecordId) {
2826
- const row = await this.myShipRow(shipName);
2909
+ async beatEngaged(shipName2, connectionName, ticketRecordId) {
2910
+ const row = await this.myShipRow(shipName2);
2827
2911
  if (!row) return;
2828
2912
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
2829
2913
  last_seen: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2845,14 +2929,14 @@ var Tracker = class {
2845
2929
  * tunnel client itself still runs the tunnel either way, it just has
2846
2930
  * nowhere on the board to report the state.
2847
2931
  */
2848
- async updateTunnelState(shipName, patch2) {
2849
- const row = await this.myShipRow(shipName);
2932
+ async updateTunnelState(shipName2, patch2) {
2933
+ const row = await this.myShipRow(shipName2);
2850
2934
  if (!row) return;
2851
2935
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
2852
2936
  }
2853
2937
  /** The other half of `beatEngaged` — clears engagement back to idle. */
2854
- async beatIdle(shipName) {
2855
- const row = await this.myShipRow(shipName);
2938
+ async beatIdle(shipName2) {
2939
+ const row = await this.myShipRow(shipName2);
2856
2940
  if (!row) return;
2857
2941
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
2858
2942
  last_seen: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3084,90 +3168,6 @@ var Tracker = class {
3084
3168
  }
3085
3169
  };
3086
3170
 
3087
- // src/roster.ts
3088
- function memberByIdentity(roster, identityId) {
3089
- if (!identityId) return void 0;
3090
- for (const m of roster.values()) if (m.userId === identityId) return m;
3091
- return void 0;
3092
- }
3093
- function crewLabel(m) {
3094
- if (!m) return null;
3095
- if (!m.role) return m.name;
3096
- if (m.name.toLowerCase().includes(m.role.toLowerCase())) return m.name;
3097
- return `${m.name} (${m.role})`;
3098
- }
3099
- function isHold(roster, id) {
3100
- if (!id) return false;
3101
- return roster.get(id)?.kind === "hold";
3102
- }
3103
- function holdIds(roster) {
3104
- return [...roster.values()].filter((m) => m.kind === "hold").map((m) => m.id);
3105
- }
3106
- function seats(roster) {
3107
- return [...roster.values()].filter((m) => m.kind === "seat");
3108
- }
3109
- function holds(roster) {
3110
- return [...roster.values()].filter((m) => m.kind === "hold");
3111
- }
3112
- function buildRoster(configured, crewRows) {
3113
- const rows = new Map(crewRows.map((r) => [r.id, r]));
3114
- const roster = /* @__PURE__ */ new Map();
3115
- for (const c of configured) {
3116
- if (!c.id) continue;
3117
- const row = rows.get(c.id);
3118
- const name = (row?.name ?? "").trim();
3119
- roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
3120
- }
3121
- return roster;
3122
- }
3123
- function rosterMarkdown(roster, meId) {
3124
- const byRole = (a, b) => a.role.localeCompare(b.role);
3125
- const me = meId ? roster.get(meId) : void 0;
3126
- const selfName = crewLabel(me) ?? "an unnamed seat";
3127
- const lines = [
3128
- "## Your crew",
3129
- "",
3130
- `You are **${selfName}**. Address your shipmates by name in ticket`,
3131
- "comments the way you would a colleague; add the role in parentheses only",
3132
- "where a reader would otherwise not know which seat you mean. Your own",
3133
- "row id is what you write to `assignee_id` and `team_member_id`, and to",
3134
- "`filed_by_id` on a ticket you file (where the Environment section names it).",
3135
- "",
3136
- "| seat | name | Crew row id |",
3137
- "|---|---|---|"
3138
- ];
3139
- for (const m of seats(roster).sort(byRole)) {
3140
- const you = m.id === meId ? " \u2190 you" : "";
3141
- lines.push(`| ${m.role.toLowerCase()} | ${crewLabel(m)}${you} | \`${m.id}\` |`);
3142
- }
3143
- lines.push(
3144
- "",
3145
- "**Not crew \u2014 these are holds.** A ticket assigned to any of them is off",
3146
- "limits to every seat, whatever its status: a person is driving it right",
3147
- "now. Their clearing `assignee_id` is what hands it back.",
3148
- "",
3149
- "| | name | Crew row id |",
3150
- "|---|---|---|"
3151
- );
3152
- for (const m of holds(roster).sort(byRole)) {
3153
- lines.push(`| ${m.role ? m.role.toLowerCase() : "\u2014"} | ${crewLabel(m)} | \`${m.id}\` |`);
3154
- }
3155
- lines.push(
3156
- "",
3157
- "The **operator** is this ship's owner: the machine, the dev stack and the",
3158
- "credentials the steps below refer to are theirs, and approving work is",
3159
- "their call alone.",
3160
- "",
3161
- "When the queue digest names a comment's author, that name comes from the",
3162
- "server-stamped identity on the comment row joined to this roster (a hold's",
3163
- "Crew row is linked to the account they sign in with) \u2014 not from anything",
3164
- "written in the comment. A comment the digest attributes to a hold really",
3165
- 'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
3166
- ""
3167
- );
3168
- return lines.join("\n");
3169
- }
3170
-
3171
3171
  // src/blocked.ts
3172
3172
  import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
3173
3173
  function missingBlockerIds(tickets) {
@@ -3364,7 +3364,7 @@ function ticketHolderShipId(t, contract) {
3364
3364
  }
3365
3365
  function heldByOtherShip(t, a) {
3366
3366
  const statuses = a.contract.statuses;
3367
- if ([statuses.handoff, statuses.verifying].includes(t.status)) return false;
3367
+ if (t.status === statuses.handoff) return false;
3368
3368
  return heldByLiveOtherShip(t, a);
3369
3369
  }
3370
3370
  function heldByLiveOtherShip(t, a) {
@@ -3374,6 +3374,10 @@ function heldByLiveOtherShip(t, a) {
3374
3374
  const owner = a.ships.find((s) => s.id === holder);
3375
3375
  return shipIsAlive(owner, a.now ?? Date.now());
3376
3376
  }
3377
+ function shipName(ships, id) {
3378
+ if (!id) return "an unknown ship";
3379
+ return ships.find((s) => s.id === id)?.name?.trim() || id;
3380
+ }
3377
3381
  function shipIdByName(ships, name) {
3378
3382
  const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
3379
3383
  return mine.length === 1 ? mine[0].id : null;
@@ -3406,6 +3410,46 @@ async function applyHoldReleases(writer, tickets, contract, emit) {
3406
3410
  }
3407
3411
  return cleared;
3408
3412
  }
3413
+ async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
3414
+ if (!result.tookOverFrom || !result.ticket) return;
3415
+ let sha = null;
3416
+ try {
3417
+ sha = continuingFrom(result.ticket);
3418
+ } catch {
3419
+ }
3420
+ try {
3421
+ await tracker.postEvent(
3422
+ result.ticket.id,
3423
+ `Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
3424
+ seat
3425
+ );
3426
+ } catch {
3427
+ }
3428
+ }
3429
+ function originBranchSha(dir, t, rev) {
3430
+ const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
3431
+ const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
3432
+ for (const n of names) {
3433
+ const sha = rev(dir, `origin/${n}`);
3434
+ if (sha) return sha.slice(0, 7);
3435
+ }
3436
+ return null;
3437
+ }
3438
+ function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
3439
+ const inFlight = [contract.statuses.building, contract.statuses.verifying];
3440
+ const out = [];
3441
+ for (const t of tickets) {
3442
+ if (!inFlight.includes(t.status)) continue;
3443
+ if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
3444
+ const holder = ticketHolderShipId(t, contract);
3445
+ if (!holder) {
3446
+ out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
3447
+ } else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
3448
+ out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
3449
+ }
3450
+ }
3451
+ return out;
3452
+ }
3409
3453
 
3410
3454
  // src/select.ts
3411
3455
  var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
@@ -3463,7 +3507,7 @@ function buildingRoleHasWork(role, i) {
3463
3507
  }
3464
3508
  function qaRoleHasWork(i) {
3465
3509
  if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
3466
- const n = qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds)).length;
3510
+ const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
3467
3511
  return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
3468
3512
  }
3469
3513
  function triageRoleHasWork(i) {
@@ -3479,7 +3523,7 @@ function roleHasWork(role, i) {
3479
3523
  return buildingRoleHasWork(role, i);
3480
3524
  }
3481
3525
  function roleCandidates(role, i) {
3482
- if (role === "qa") return qaSlice(i.tickets).filter((t) => !isHeld(t, i.holds));
3526
+ if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
3483
3527
  if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
3484
3528
  if (role === "pair") return [];
3485
3529
  const me = i.seats[role];
@@ -3488,6 +3532,9 @@ function roleCandidates(role, i) {
3488
3532
  (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)
3489
3533
  );
3490
3534
  }
3535
+ function claimStatuses(role, c) {
3536
+ return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
3537
+ }
3491
3538
  function rankedCandidates(role, i) {
3492
3539
  const c = i.contract ?? DEFAULT_CONTRACT;
3493
3540
  return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
@@ -3695,6 +3742,14 @@ function newFromOthers(i, ticketId) {
3695
3742
  ).length;
3696
3743
  }
3697
3744
  function who(t, i) {
3745
+ return whoAssigned(t, i) + heldBySuffix(t, i);
3746
+ }
3747
+ function heldBySuffix(t, i) {
3748
+ if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
3749
+ const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
3750
+ return ` (held by ${name || "an unknown ship"})`;
3751
+ }
3752
+ function whoAssigned(t, i) {
3698
3753
  if (!t.assignee_id) return "unassigned";
3699
3754
  if (t.assignee_id === i.me) return "you";
3700
3755
  const label = crewLabel(i.roster.get(t.assignee_id));
@@ -4490,6 +4545,10 @@ function rosterFor(d, route, role) {
4490
4545
 
4491
4546
  // src/claim.ts
4492
4547
  import { StaleWriteError as StaleWriteError4 } from "@tablation/client";
4548
+ function displacedHolder(t, affinity) {
4549
+ const v = affinity ? t[affinity.column] : null;
4550
+ return typeof v === "string" && v && v !== affinity.shipId ? v : null;
4551
+ }
4493
4552
  async function stampResumption(tracker, t, affinity) {
4494
4553
  let current = t;
4495
4554
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -4525,17 +4584,17 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4525
4584
  }
4526
4585
  if (t.status !== approvedStatus) {
4527
4586
  if (affinity?.isLiveHoldElsewhere?.(t)) {
4528
- return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
4587
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
4529
4588
  }
4530
4589
  if (affinity && t[affinity.column] !== affinity.shipId) {
4531
4590
  const stamp = await stampResumption(tracker, t, affinity);
4532
4591
  if (stamp.outcome === "stamped") {
4533
- return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false };
4592
+ return { ticket: stamp.ticket, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: displacedHolder(t, affinity) };
4534
4593
  }
4535
4594
  (stamp.outcome === "held" ? held : contended).push(t.issue_id);
4536
4595
  continue;
4537
4596
  }
4538
- return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false };
4597
+ return { ticket: t, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
4539
4598
  }
4540
4599
  try {
4541
4600
  const claimed = await tracker.updateTicket(
@@ -4548,7 +4607,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4548
4607
  t.updated_at
4549
4608
  );
4550
4609
  const reaccepted = !!affinity && t[affinity.column] === affinity.shipId;
4551
- return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted };
4610
+ return { ticket: { ...t, ...claimed }, claimed: true, contended, unservable, held, reaccepted, tookOverFrom: displacedHolder(t, affinity) };
4552
4611
  } catch (e) {
4553
4612
  if (e instanceof StaleWriteError4) {
4554
4613
  contended.push(t.issue_id);
@@ -4557,7 +4616,7 @@ async function resolveTopCandidate(tracker, candidates, seat, approvedStatus, bu
4557
4616
  throw e;
4558
4617
  }
4559
4618
  }
4560
- return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false };
4619
+ return { ticket: null, claimed: false, contended, unservable, held, reaccepted: false, tookOverFrom: null };
4561
4620
  }
4562
4621
 
4563
4622
  // src/conflict.ts
@@ -4944,6 +5003,15 @@ function renderEnvironment(i) {
4944
5003
  "impossible."
4945
5004
  );
4946
5005
  }
5006
+ if (i.shipName) {
5007
+ lines.push(
5008
+ "",
5009
+ "## Ships",
5010
+ "",
5011
+ `This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
5012
+ "another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
5013
+ );
5014
+ }
4947
5015
  lines.push(
4948
5016
  "",
4949
5017
  "## What the columns are called here",
@@ -9178,7 +9246,7 @@ async function runRoleAgent(role, decision, ctx) {
9178
9246
  const tracker = new Tracker(route, ship);
9179
9247
  let ticketHint = decision.actionable.top?.issue_id;
9180
9248
  let workingId = decision.actionable.top?.id ?? null;
9181
- if (role === "dev" || role === "design") {
9249
+ if (role === "dev" || role === "design" || role === "qa") {
9182
9250
  const seat = route.resolved?.seats[role];
9183
9251
  if (seat) {
9184
9252
  const candidates = rankedCandidates(role, decision.selectionInput);
@@ -9187,8 +9255,8 @@ async function runRoleAgent(role, decision, ctx) {
9187
9255
  tracker,
9188
9256
  candidates,
9189
9257
  seat,
9190
- contract.statuses.approved,
9191
- contract.statuses.building,
9258
+ claimStatuses(role, contract).approved,
9259
+ claimStatuses(role, contract).building,
9192
9260
  (t) => dirForRepo(route, t.repo_id) !== null,
9193
9261
  withHoldCheck(await tracker.claimAffinity(), decision.selectionInput)
9194
9262
  );
@@ -9208,6 +9276,17 @@ async function runRoleAgent(role, decision, ctx) {
9208
9276
  emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role });
9209
9277
  }
9210
9278
  await noteReaccepted(tracker, result, seat, ship.name);
9279
+ await noteTakeover(
9280
+ tracker,
9281
+ result,
9282
+ seat,
9283
+ decision.selectionInput.ships?.rows ?? [],
9284
+ ship.name,
9285
+ (t) => {
9286
+ const d = dirForRepo(route, t.repo_id);
9287
+ return d ? originBranchSha(d, t, resolve2) : null;
9288
+ }
9289
+ );
9211
9290
  if (!result.ticket) {
9212
9291
  emit.emit(`${role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
9213
9292
  return;
@@ -9296,7 +9375,7 @@ async function buildEnvironment(route, ship, ticket) {
9296
9375
  };
9297
9376
  });
9298
9377
  const authorship = await tracker.authorshipProbe().catch(() => null);
9299
- return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
9378
+ return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, shipName: ship.name });
9300
9379
  }
9301
9380
 
9302
9381
  // src/cli.ts
@@ -9913,7 +9992,8 @@ ${changed2.map((c) => ` - ${c}
9913
9992
  repos: await resolvedRepos(c),
9914
9993
  contract,
9915
9994
  sourceTicket: ticket,
9916
- authorship: await tracker.authorshipProbe().catch(() => null)
9995
+ authorship: await tracker.authorshipProbe().catch(() => null),
9996
+ shipName: cfg.ship.name
9917
9997
  });
9918
9998
  }
9919
9999
  const unplaceableReported = /* @__PURE__ */ new Set();
@@ -10239,7 +10319,7 @@ ${changed2.map((c) => ` - ${c}
10239
10319
  const fleetTracker = new Tracker(w.route, cfg.ship);
10240
10320
  let fleetTicketHint = w.decision.actionable.top?.issue_id;
10241
10321
  let fleetWorkingId = w.decision.actionable.top?.id ?? null;
10242
- if (w.role === "dev" || w.role === "design") {
10322
+ if (w.role === "dev" || w.role === "design" || w.role === "qa") {
10243
10323
  const seat = w.route.resolved?.seats[w.role];
10244
10324
  if (seat) {
10245
10325
  const candidates = rankedCandidates(w.role, w.decision.selectionInput);
@@ -10248,8 +10328,8 @@ ${changed2.map((c) => ` - ${c}
10248
10328
  fleetTracker,
10249
10329
  candidates,
10250
10330
  seat,
10251
- contract.statuses.approved,
10252
- contract.statuses.building,
10331
+ claimStatuses(w.role, contract).approved,
10332
+ claimStatuses(w.role, contract).building,
10253
10333
  (t) => dirForRepo(w.route, t.repo_id) !== null,
10254
10334
  withHoldCheck(await fleetTracker.claimAffinity(), w.decision.selectionInput)
10255
10335
  );
@@ -10269,6 +10349,17 @@ ${changed2.map((c) => ` - ${c}
10269
10349
  wemit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: w.role });
10270
10350
  }
10271
10351
  await noteReaccepted(fleetTracker, result, seat, cfg.ship.name);
10352
+ await noteTakeover(
10353
+ fleetTracker,
10354
+ result,
10355
+ seat,
10356
+ w.decision.selectionInput.ships?.rows ?? [],
10357
+ cfg.ship.name,
10358
+ (t) => {
10359
+ const d = dirForRepo(w.route, t.repo_id);
10360
+ return d ? originBranchSha(d, t, resolve2) : null;
10361
+ }
10362
+ );
10272
10363
  if (!result.ticket) {
10273
10364
  wemit.emit(`${w.role} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10274
10365
  if (!skipInlineRelease) await releaseFleet();
@@ -10420,7 +10511,7 @@ ${changed2.map((c) => ` - ${c}
10420
10511
  }
10421
10512
  let ticketHint = decision.actionable.top?.issue_id;
10422
10513
  let workingId = decision.actionable.top?.id ?? null;
10423
- if (!dryRun && (current === "dev" || current === "design")) {
10514
+ if (!dryRun && (current === "dev" || current === "design" || current === "qa")) {
10424
10515
  const seat = route.resolved?.seats[current];
10425
10516
  if (seat) {
10426
10517
  const candidates = rankedCandidates(current, decision.selectionInput);
@@ -10429,8 +10520,8 @@ ${changed2.map((c) => ` - ${c}
10429
10520
  tracker2,
10430
10521
  candidates,
10431
10522
  seat,
10432
- contract.statuses.approved,
10433
- contract.statuses.building,
10523
+ claimStatuses(current, contract).approved,
10524
+ claimStatuses(current, contract).building,
10434
10525
  (t) => dirForRepo(route, t.repo_id) !== null,
10435
10526
  withHoldCheck(await tracker2.claimAffinity(), decision.selectionInput)
10436
10527
  );
@@ -10450,6 +10541,17 @@ ${changed2.map((c) => ` - ${c}
10450
10541
  emit.emit(`skipped ${result.held.join(", ")} \u2014 held by another live ship`, { step: "select", role: current });
10451
10542
  }
10452
10543
  await noteReaccepted(tracker2, result, seat, cfg.ship.name);
10544
+ await noteTakeover(
10545
+ tracker2,
10546
+ result,
10547
+ seat,
10548
+ decision.selectionInput.ships?.rows ?? [],
10549
+ cfg.ship.name,
10550
+ (t) => {
10551
+ const d = dirForRepo(route, t.repo_id);
10552
+ return d ? originBranchSha(d, t, resolve2) : null;
10553
+ }
10554
+ );
10453
10555
  if (!result.ticket) {
10454
10556
  emit.emit(`${current} skipped this cycle \u2014 every candidate was already claimed elsewhere`);
10455
10557
  dropLock();
@@ -11919,6 +12021,19 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
11919
12021
  );
11920
12022
  }
11921
12023
  }
12024
+ if (ships.length > 0) {
12025
+ try {
12026
+ const humanHolds = new Set(holdIds(buildRoster(configuredMembers(route), crewRows)));
12027
+ const warnings = holdWarnings(await tracker.openTickets(), ships, tracker.contract, humanHolds);
12028
+ process.stdout.write(
12029
+ warnings.length === 0 ? "ship holds: every in-flight ticket is held by a live ship\n" : warnings.map((w) => `ship holds: WARNING ${w}
12030
+ `).join("")
12031
+ );
12032
+ } catch (e) {
12033
+ process.stdout.write(`ship holds: could not check \u2014 ${e.message}
12034
+ `);
12035
+ }
12036
+ }
11922
12037
  const schedulerPath = pathFor(cfg.ship);
11923
12038
  const agentBinPath = findOnPath(cfg.ship.agent.bin, schedulerPath);
11924
12039
  const gitPath = findOnPath("git", schedulerPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.12.1",
3
+ "version": "0.13.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",
@@ -38,8 +38,14 @@ human review, so "probably fine" is not a pass.
38
38
  unfinished) always comes before any `fixed` one. One ticket per run;
39
39
  the loop gives your lane the next cycle too while your queue is
40
40
  non-empty.
41
- 2. **Claim it:** set `status` to `qa` and `assignee_id` to yourself, then
42
- post a short comment saying you've started verifying.
41
+ 2. **Claim it.** The crew has usually already done this for you: a ticket
42
+ it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
43
+ this ship — that stamp is what keeps another ship's QA seat off the same
44
+ ticket (a second verdict on one branch has let a defective one merge). If
45
+ the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
46
+ yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
47
+ the ticket: if `held_by_ship_id` now names another ship, stop — that ship
48
+ is verifying it. Post a short comment saying you've started verifying.
43
49
  3. **Read the case, not just the ticket.** Fetch the full record
44
50
  (`description`, `repro_steps`, `resolution_note`) *and* every comment on
45
51
  it. The builder's own closing comment — what it changed, how it says it
@@ -107,6 +113,13 @@ human review, so "probably fine" is not a pass.
107
113
  8. **Then give the verdict**, with a comment that shows your work — what
108
114
  you ran, what you saw, what you deliberately didn't cover:
109
115
 
116
+ **Before you write it, re-read the ticket.** If `updated_at` has moved
117
+ since you claimed it, or `held_by_ship_id` no longer names this ship,
118
+ someone else has acted on it (a bounce, a verdict, a takeover): do not
119
+ overwrite their write — say so in a comment and stop. Send the verdict
120
+ write itself conditional on the `updated_at` you just read, with an
121
+ `X-Expected-Updated-At` header; a 409 means the same thing.
122
+
110
123
  - **It holds up** → `status` = `verified`, and clear `assignee_id`. That
111
124
  is the merge trigger: the release phase will squash-merge the branch,
112
125
  bump the version, and deploy this cycle or the next. Say in
@@ -114,11 +127,11 @@ human review, so "probably fine" is not a pass.
114
127
  covered.
115
128
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
116
129
  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. **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
130
+ `needs_design` is false or null, the design seat if true. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
131
+ hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
132
+ naming this ship's seat would hand the bounce to a ship that never built
133
+ it. An unassigned, unheld `in_progress` ticket is picked up by whichever
134
+ ship's building seat polls next, from the branch on origin. The comment
122
135
  must be actionable:
123
136
  exactly what you did, what you expected, what happened, with a
124
137
  screenshot or the failing output. This includes a red suite, a
@@ -36,8 +36,14 @@ human review, so "probably fine" is not a pass.
36
36
  unfinished) always comes before any `fixed` one. One ticket per run;
37
37
  the loop gives your lane the next cycle too while your queue is
38
38
  non-empty.
39
- 2. **Claim it:** set `status` to `qa` and `assignee_id` to yourself, then
40
- post a short comment saying you've started verifying.
39
+ 2. **Claim it.** The crew has usually already done this for you: a ticket
40
+ it handed you is at `qa`, assigned to you, with `held_by_ship_id` naming
41
+ this ship — that stamp is what keeps another ship's QA seat off the same
42
+ ticket (a second verdict on one branch has let a defective one merge). If
43
+ the ticket is still at `fixed`, set `status` to `qa`, `assignee_id` to
44
+ yourself and `held_by_ship_id` to this ship's own Ships row, then re-read
45
+ the ticket: if `held_by_ship_id` now names another ship, stop — that ship
46
+ is verifying it. Post a short comment saying you've started verifying.
41
47
  3. **Read the case, not just the ticket.** Fetch the full record
42
48
  (`description`, `repro_steps`, `resolution_note`) *and* every comment on
43
49
  it. The builder's own closing comment — what it changed, how it says it
@@ -98,17 +104,24 @@ human review, so "probably fine" is not a pass.
98
104
  8. **Then give the verdict**, with a comment that shows your work — what
99
105
  you ran, what you saw, what you deliberately didn't cover:
100
106
 
107
+ **Before you write it, re-read the ticket.** If `updated_at` has moved
108
+ since you claimed it, or `held_by_ship_id` no longer names this ship,
109
+ someone else has acted on it (a bounce, a verdict, a takeover): do not
110
+ overwrite their write — say so in a comment and stop. Send the verdict
111
+ write itself conditional on the `updated_at` you just read, with an
112
+ `X-Expected-Updated-At` header; a 409 means the same thing.
113
+
101
114
  - **It holds up** → `status` = `verified`, and clear `assignee_id`. That
102
115
  is the merge trigger: the release phase will squash-merge the branch,
103
116
  bump the version, and deploy this cycle or the next. Say in
104
117
  the comment what you exercised, so the record shows what "verified"
105
118
  covered.
106
119
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
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
120
+ seat's own Crew row id from the roster above. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
121
+ hold on a `qa` ticket is yours, as verifier, and seats are per ship, so
122
+ naming this ship's seat would hand the bounce to a ship that never built
123
+ it. An unassigned, unheld `in_progress` ticket is picked up by whichever
124
+ ship's building seat polls next, from the branch on origin. The comment must be
112
125
  actionable:
113
126
  exactly what you did, what you expected, what happened, with a
114
127
  screenshot or the failing output. This includes a red suite, a