@tablation/crew 0.6.1 → 0.7.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
@@ -824,6 +824,7 @@ function parseResolved(raw, m, where) {
824
824
  operator: m.req(raw.operator, `${where}.resolved.operator`),
825
825
  holds: holds2,
826
826
  contract: raw.contract ?? void 0,
827
+ ...typeof raw.releaseSeat === "string" && raw.releaseSeat ? { releaseSeat: raw.releaseSeat } : {},
827
828
  ...Object.keys(agentPersonas).length > 0 ? { agentPersonas } : {},
828
829
  ...Object.keys(agentSkills).length > 0 ? { agentSkills } : {}
829
830
  };
@@ -1083,6 +1084,10 @@ async function hydrateApiKeys(routes, getStore = getSessionStore) {
1083
1084
  }
1084
1085
  }
1085
1086
  }
1087
+ function releaseSeat(rt) {
1088
+ const r = rt.resolved;
1089
+ return r?.releaseSeat ?? r?.seats.dev ?? r?.seats.qa ?? "";
1090
+ }
1086
1091
  function configuredMembers(rt) {
1087
1092
  const r = rt.resolved;
1088
1093
  if (!r) throw new ConfigError(`route "${rt.route}" has no resolved ids \u2014 run \`crew connect\``);
@@ -1675,7 +1680,9 @@ var DEFAULT_CONTRACT = {
1675
1680
  updatedAt: "updated_at",
1676
1681
  needsDesign: "needs_design",
1677
1682
  reportType: "report_type",
1678
- epic: "epic_id"
1683
+ epic: "epic_id",
1684
+ author: "filed_by_id",
1685
+ ship: "ship_id"
1679
1686
  },
1680
1687
  comments: {
1681
1688
  parent: "ticket_id",
@@ -1683,7 +1690,8 @@ var DEFAULT_CONTRACT = {
1683
1690
  author: "team_member_id",
1684
1691
  kind: "kind",
1685
1692
  eventKind: "event",
1686
- createdAt: "created_at"
1693
+ createdAt: "created_at",
1694
+ ship: "ship_id"
1687
1695
  },
1688
1696
  statuses: {
1689
1697
  open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
@@ -1982,6 +1990,13 @@ function findOnPath(bin, searchPath) {
1982
1990
  }
1983
1991
  return void 0;
1984
1992
  }
1993
+ var COMMON_DOCKER_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", join5(homedir2(), ".docker", "bin")].join(":");
1994
+ function dockerPathProblem(ship, shellPath = process.env.PATH ?? "") {
1995
+ const schedulerPath = pathFor(ship);
1996
+ if (findOnPath("docker", schedulerPath)) return void 0;
1997
+ const inShell = findOnPath("docker", shellPath);
1998
+ return inShell ? `\`docker\` is not on the scheduler PATH (${schedulerPath}) but is at ${inShell} in your shell; add ${dirname3(inShell)} to ship.extraPath in crew.yaml and run \`crew install\`` : `\`docker\` is not on the scheduler PATH (${schedulerPath}) or your shell PATH; install Docker, then add its directory to ship.extraPath in crew.yaml and run \`crew install\``;
1999
+ }
1985
2000
  function xmlEscape(s) {
1986
2001
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1987
2002
  }
@@ -2471,6 +2486,12 @@ var Tracker = class {
2471
2486
  client;
2472
2487
  models;
2473
2488
  userAgent;
2489
+ shipName;
2490
+ /**
2491
+ * The once-per-process authorship probe (CREW-1371), memoised as a promise
2492
+ * so concurrent first writes share one round of field-metadata reads.
2493
+ */
2494
+ authorship;
2474
2495
  stateDir;
2475
2496
  constructor(route, ship) {
2476
2497
  this.route = route;
@@ -2482,6 +2503,7 @@ var Tracker = class {
2482
2503
  this.models = route.resolved.models;
2483
2504
  this.contract = resolveContract(route.contract);
2484
2505
  this.userAgent = ship.userAgent;
2506
+ this.shipName = ship.name;
2485
2507
  this.stateDir = ship.stateDir;
2486
2508
  this.client = new TablationClient({
2487
2509
  baseUrl: `${route.baseUrl}/api`,
@@ -2834,13 +2856,60 @@ var Tracker = class {
2834
2856
  * someone else" wake signal.
2835
2857
  */
2836
2858
  async postEvent(ticketId, body, memberId) {
2859
+ const auth = await this.authorshipProbe();
2837
2860
  await this.client.records.create(this.models.comments, {
2838
2861
  ticket_id: ticketId,
2839
2862
  body,
2840
2863
  team_member_id: memberId,
2841
- kind: "event"
2864
+ kind: "event",
2865
+ ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
2842
2866
  });
2843
2867
  }
2868
+ /**
2869
+ * The authorship probe (CREW-1371), read once per process: which optional
2870
+ * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
2871
+ * ship's own Ships row id. Memoised as a promise so concurrent first writes
2872
+ * share one round of metadata reads. A table whose metadata cannot be read
2873
+ * reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
2874
+ */
2875
+ authorshipProbe() {
2876
+ this.authorship ??= this.probeAuthorship();
2877
+ return this.authorship;
2878
+ }
2879
+ async probeAuthorship() {
2880
+ const [issueFields, commentFields, shipRow] = await Promise.all([
2881
+ this.columnNames(this.models.issues),
2882
+ this.columnNames(this.models.comments),
2883
+ // No name (a caller that only beats or patches, never stamps) means no row to find.
2884
+ this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
2885
+ ]);
2886
+ return {
2887
+ issueAuthor: issueFields.has(this.contract.columns.author),
2888
+ issueShip: issueFields.has(this.contract.columns.ship),
2889
+ commentShip: commentFields.has(this.contract.comments.ship),
2890
+ shipId: shipRow?.id ?? null
2891
+ };
2892
+ }
2893
+ async columnNames(modelId) {
2894
+ try {
2895
+ const model = await this.client.dataModels.get(modelId);
2896
+ return new Set(model.fields.map((f) => f.columnName));
2897
+ } catch {
2898
+ return /* @__PURE__ */ new Set();
2899
+ }
2900
+ }
2901
+ /**
2902
+ * The authorship columns this workspace is missing, as `table.column` names
2903
+ * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
2904
+ */
2905
+ async missingAuthorshipColumns() {
2906
+ const auth = await this.authorshipProbe();
2907
+ const missing = [];
2908
+ if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
2909
+ if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
2910
+ if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
2911
+ return missing;
2912
+ }
2844
2913
  /**
2845
2914
  * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
2846
2915
  * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
@@ -2882,8 +2951,13 @@ var Tracker = class {
2882
2951
  * not among them (`postEvent` above hardcodes its own fields the same
2883
2952
  * way).
2884
2953
  */
2885
- async fileTicket(fields) {
2886
- return this.client.records.create(this.models.issues, fields);
2954
+ async fileTicket(fields, memberId) {
2955
+ const auth = await this.authorshipProbe();
2956
+ return this.client.records.create(this.models.issues, {
2957
+ ...fields,
2958
+ ...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
2959
+ ...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
2960
+ });
2887
2961
  }
2888
2962
  };
2889
2963
 
@@ -2933,7 +3007,8 @@ function rosterMarkdown(roster, meId) {
2933
3007
  `You are **${selfName}**. Address your shipmates by name in ticket`,
2934
3008
  "comments the way you would a colleague; add the role in parentheses only",
2935
3009
  "where a reader would otherwise not know which seat you mean. Your own",
2936
- "row id is what you write to `assignee_id` and `team_member_id`.",
3010
+ "row id is what you write to `assignee_id` and `team_member_id`, and to",
3011
+ "`filed_by_id` on a ticket you file (where the Environment section names it).",
2937
3012
  "",
2938
3013
  "| seat | name | Crew row id |",
2939
3014
  "|---|---|---|"
@@ -3390,20 +3465,36 @@ function attentionTransitions(previous, current) {
3390
3465
  function displayKey2(t) {
3391
3466
  return t.issue_tag ?? t.issue_id ?? "?";
3392
3467
  }
3393
- function author(c, me, roster) {
3468
+ function author(c, i) {
3469
+ const { me, roster } = i;
3394
3470
  if (c.team_member_id === me) return "you";
3395
3471
  if (c.team_member_id === null || c.team_member_id === void 0) {
3396
3472
  const byIdentity = memberByIdentity(roster, c.created_by_id);
3397
3473
  if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
3398
3474
  return `${c.reporter_name ?? "anon"} (no identity)`;
3399
3475
  }
3400
- return crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship";
3476
+ return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
3477
+ }
3478
+ function shipSuffix(c, i) {
3479
+ if (!c.ship_id || c.ship_id === i.myShipId) return "";
3480
+ const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
3481
+ return name ? ` (${name})` : "";
3482
+ }
3483
+ function filedBy(t, i) {
3484
+ const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
3485
+ if (member) {
3486
+ if (member.id === i.me) return "you";
3487
+ const label = crewLabel(member) ?? member.name;
3488
+ const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
3489
+ return ship ? `${label} (${ship})` : label;
3490
+ }
3491
+ return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
3401
3492
  }
3402
3493
  function lastComment(i, ticketId) {
3403
3494
  const forTicket = i.comments.filter((c) => c.ticket_id === ticketId).sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
3404
3495
  const last = forTicket[forTicket.length - 1];
3405
3496
  if (!last) return "\u2014";
3406
- return `${last.created_at.slice(0, 16)}Z ${author(last, i.me, i.roster)}`;
3497
+ return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
3407
3498
  }
3408
3499
  function newFromOthers(i, ticketId) {
3409
3500
  return i.comments.filter(
@@ -3435,14 +3526,14 @@ ${sorted(ts).map(row).join("\n")}
3435
3526
  `;
3436
3527
  }
3437
3528
  function buildingDigest(i) {
3438
- const header = "| ticket | repo | branch | worktree | status | assignee | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|";
3529
+ const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3439
3530
  const row = (t) => {
3440
3531
  const n = newFromOthers(i, t.id);
3441
3532
  const branch = i.branchFor ? i.branchFor(t) : "";
3442
3533
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
3443
3534
  const dir = i.dirFor ? i.dirFor(t) : null;
3444
3535
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3445
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3536
+ return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3446
3537
  };
3447
3538
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
3448
3539
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -3474,7 +3565,7 @@ ${sorted(ts).map(blockedRow).join("\n")}
3474
3565
  ]);
3475
3566
  }
3476
3567
  function qaDigest(i) {
3477
- const header = "| ticket | repo | status | built by | assignee | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3568
+ const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3478
3569
  const branchCell = (t) => {
3479
3570
  if (i.dirFor && i.dirFor(t) === null) return "\u2014";
3480
3571
  if (!i.existingBranchFor) return "";
@@ -3489,7 +3580,7 @@ function qaDigest(i) {
3489
3580
  const n = newFromOthers(i, t.id);
3490
3581
  const dir = i.dirFor ? i.dirFor(t) : null;
3491
3582
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3492
- return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3583
+ return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3493
3584
  };
3494
3585
  return stream([
3495
3586
  "## Current queue \u2014 built for you by the poll\n",
@@ -3952,11 +4043,12 @@ async function decideCycle(o) {
3952
4043
  const { route, ship, state, emit } = o;
3953
4044
  const tracker = new Tracker(route, ship);
3954
4045
  emit.enter("poll");
3955
- const [tickets, comments, crewRows, epicRows] = await Promise.all([
4046
+ const [tickets, comments, crewRows, epicRows, ships] = await Promise.all([
3956
4047
  tracker.openTickets(),
3957
4048
  tracker.comments(200),
3958
4049
  tracker.crewRows(),
3959
- tracker.epicRows()
4050
+ tracker.epicRows(),
4051
+ tracker.shipRows()
3960
4052
  ]);
3961
4053
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
3962
4054
  data: { tickets: tickets.length, comments: comments.length }
@@ -4069,6 +4161,7 @@ async function decideCycle(o) {
4069
4161
  tickets,
4070
4162
  comments,
4071
4163
  roster,
4164
+ ships,
4072
4165
  blocked,
4073
4166
  info,
4074
4167
  sweep,
@@ -4147,6 +4240,10 @@ function writeDigest(o, d, role, stateDir) {
4147
4240
  comments: d.comments,
4148
4241
  me,
4149
4242
  roster: d.roster,
4243
+ // CREW-1371: a comment from another ship is labelled with that ship's
4244
+ // name, so two ships under one API key are distinguishable on the queue.
4245
+ ships: d.ships,
4246
+ myShipId: d.ships.find((s) => (s.name ?? "").trim() === o.ship.name.trim())?.id ?? null,
4150
4247
  watermark: d.watermark,
4151
4248
  blocked: d.blocked,
4152
4249
  blockerInfo: d.info
@@ -4422,6 +4519,14 @@ import { createHash as createHash2 } from "crypto";
4422
4519
  // src/environment.ts
4423
4520
  import { basename as basename4 } from "path";
4424
4521
  var API_KEY_VAR = "CREW_API_KEY";
4522
+ function authorshipBullets(a, contract) {
4523
+ if (!a) return [];
4524
+ return [
4525
+ ...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
4526
+ ...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
4527
+ ...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
4528
+ ];
4529
+ }
4425
4530
  var HOOK_PURPOSE = [
4426
4531
  ["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
4427
4532
  ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
@@ -4628,6 +4733,7 @@ function renderEnvironment(i) {
4628
4733
  ...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
4629
4734
  (r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
4630
4735
  ),
4736
+ ...authorshipBullets(i.authorship, contract),
4631
4737
  "",
4632
4738
  ...i.sourceTicket ? [
4633
4739
  `Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
@@ -5164,8 +5270,7 @@ async function runWizard(p, env) {
5164
5270
  }
5165
5271
  const nodeDir = dirname6(env.nodePath);
5166
5272
  const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
5167
- const extraPath = deriveExtraPath([nodeDir, pnpmPath ? dirname6(pnpmPath) : void 0]);
5168
- if (extraPath) p.say(`Hooks will get ${extraPath} on their PATH (where node and pnpm live).`);
5273
+ const baseDirs = [nodeDir, pnpmPath ? dirname6(pnpmPath) : void 0];
5169
5274
  p.say(
5170
5275
  `
5171
5276
  Host Passengers lets this machine run the agent sessions that other people's
@@ -5175,6 +5280,11 @@ Tablation workspaces ask for, in a container. More: ${HOST_PASSENGERS_DOCS_URL}`
5175
5280
  if (hostPassengers && !env.dockerAvailable) {
5176
5281
  p.say("Docker is not available here, so nothing will be hosted until it is. Your answer is recorded anyway.");
5177
5282
  }
5283
+ const dockerPath = hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
5284
+ const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname6(dockerPath) : void 0]);
5285
+ if (extraPath) {
5286
+ p.say(`Hooks will get ${extraPath} on their PATH (where node, pnpm${dockerPath ? " and docker" : ""} live).`);
5287
+ }
5178
5288
  return { name, platform: env.platform, agentBin, extraPath, hostPassengers };
5179
5289
  }
5180
5290
  function renderShipBlock(a) {
@@ -6128,7 +6238,7 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
6128
6238
  report_type: "bug",
6129
6239
  severity: "s2",
6130
6240
  reporter_name: "Crew release"
6131
- });
6241
+ }, memberId);
6132
6242
  const label = created.issue_id ? displayKey(created) : created.id;
6133
6243
  log.warn(`release failure \u2014 filed ${label}`, { step: "release" });
6134
6244
  return { kind: "filed", issueId: label };
@@ -7158,7 +7268,19 @@ async function discover(o) {
7158
7268
  }
7159
7269
  const pairId = seatFor(scoped, "pair") || (shipRowId ? seatFor(unclaimed, "pair") : void 0);
7160
7270
  if (pairId) out.seats.pair = pairId;
7161
- const seatIds = new Set(Object.values(out.seats));
7271
+ if (shipRowId) {
7272
+ const releaseId = seatFor(scoped, "release");
7273
+ if (releaseId) out.releaseSeat = releaseId;
7274
+ else {
7275
+ const created = await post2(o, `/data-models/${out.models.crew}/records`, {
7276
+ name: "Release agent",
7277
+ ship_id: shipRowId
7278
+ });
7279
+ out.releaseSeat = created.id;
7280
+ provisioning.push("created Crew row for the release seat");
7281
+ }
7282
+ }
7283
+ const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
7162
7284
  out.holds = rows.filter((r) => !seatIds.has(r.id)).map((r) => ({ id: r.id, name: r.name ?? "" }));
7163
7285
  if (out.holds.length === 0) {
7164
7286
  problems.push("no non-seat Crew rows \u2014 which row is the operator?");
@@ -8732,7 +8854,8 @@ async function runRoleAgent(role, decision, ctx) {
8732
8854
  }
8733
8855
  }
8734
8856
  async function buildEnvironment(route, ship, ticket) {
8735
- const contract = new Tracker(route, ship).contract;
8857
+ const tracker = new Tracker(route, ship);
8858
+ const contract = tracker.contract;
8736
8859
  const repos = reposOf(route).map((t) => {
8737
8860
  const id = repoIdForName(route, t.name);
8738
8861
  const o = route.repoOverrides[t.name];
@@ -8747,7 +8870,8 @@ async function buildEnvironment(route, ship, ticket) {
8747
8870
  }, t.dir)
8748
8871
  };
8749
8872
  });
8750
- return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket });
8873
+ const authorship = await tracker.authorshipProbe().catch(() => null);
8874
+ return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
8751
8875
  }
8752
8876
 
8753
8877
  // src/cli.ts
@@ -9063,8 +9187,9 @@ if (command === "passenger-sync-daemon") {
9063
9187
  const passengerRoutes = cfg.routes.filter((r) => r.enabled && r.hostPassengers);
9064
9188
  if (passengerRoutes.length === 0) return;
9065
9189
  if (!dockerAvailable()) {
9190
+ const pathProblem = dockerPathProblem(cfg.ship, COMMON_DOCKER_DIRS);
9066
9191
  emit.warn(
9067
- "Host Passengers is on for at least one route but Docker is not available \u2014 skipping container sync this cycle",
9192
+ `Host Passengers is on for at least one route but ${pathProblem ?? "Docker is not available"} \u2014 skipping container sync this cycle`,
9068
9193
  { step: "passengers" }
9069
9194
  );
9070
9195
  return;
@@ -9189,7 +9314,12 @@ if (command === "passenger-sync-daemon") {
9189
9314
  process.stderr.write("crew install: no scheduler support yet for Windows (Task Scheduler is planned, not built)\n");
9190
9315
  process.exit(2);
9191
9316
  }
9192
- if (!dryRun && cfg.routes.some((r) => r.enabled && r.hostPassengers) && dockerAvailable()) {
9317
+ const installPathProblem = cfg.routes.some((r) => r.enabled && r.hostPassengers) ? dockerPathProblem(cfg.ship) : void 0;
9318
+ if (installPathProblem) {
9319
+ process.stderr.write(`crew install: Host Passengers is on but ${installPathProblem}
9320
+ `);
9321
+ process.exitCode = 1;
9322
+ } else if (!dryRun && cfg.routes.some((r) => r.enabled && r.hostPassengers) && dockerAvailable()) {
9193
9323
  const image = passengerImageRef(cfg);
9194
9324
  if (imagePresent(image)) {
9195
9325
  process.stdout.write(`Host Passengers image ${image} is already present
@@ -9336,13 +9466,15 @@ ${changed2.map((c) => ` - ${c}
9336
9466
  return (await resolvedRepos(c)).map((r) => `${r.name} requires ${r.config.platform}`).join(", ");
9337
9467
  }
9338
9468
  async function environmentFor(c, ticket) {
9339
- const contract = new Tracker(c, cfg.ship).contract;
9469
+ const tracker = new Tracker(c, cfg.ship);
9470
+ const contract = tracker.contract;
9340
9471
  return renderEnvironment({
9341
9472
  route: c,
9342
9473
  userAgent: cfg.ship.userAgent,
9343
9474
  repos: await resolvedRepos(c),
9344
9475
  contract,
9345
- sourceTicket: ticket
9476
+ sourceTicket: ticket,
9477
+ authorship: await tracker.authorshipProbe().catch(() => null)
9346
9478
  });
9347
9479
  }
9348
9480
  const unplaceableReported = /* @__PURE__ */ new Set();
@@ -9444,7 +9576,7 @@ ${changed2.map((c) => ` - ${c}
9444
9576
  try {
9445
9577
  const g = outcome.gateRed;
9446
9578
  const comments = await tracker.comments();
9447
- const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9579
+ const seat = releaseSeat(c);
9448
9580
  for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
9449
9581
  if (alreadyReported(t, g, comments)) continue;
9450
9582
  await tracker.postEvent(t.id, gateRedComment(g), seat);
@@ -9462,7 +9594,7 @@ ${changed2.map((c) => ` - ${c}
9462
9594
  if (news) {
9463
9595
  const notified = await notify(c, cfg.ship, news, remit, dryRun);
9464
9596
  if (!notified && news.level === "fail") {
9465
- const memberId = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9597
+ const memberId = releaseSeat(c);
9466
9598
  await applyFailureAlert(tracker, news, scope, all, memberId, remit, dryRun).catch((e) => {
9467
9599
  remit.warn(`could not file/update a failure ticket: ${e.message}`, { step: "release" });
9468
9600
  });
@@ -9470,7 +9602,7 @@ ${changed2.map((c) => ` - ${c}
9470
9602
  }
9471
9603
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
9472
9604
  const comments = await tracker.comments();
9473
- const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9605
+ const seat = releaseSeat(c);
9474
9606
  for (const f of outcome.conflicts ?? []) {
9475
9607
  try {
9476
9608
  const bounce = planConflictBounce(
@@ -10616,6 +10748,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10616
10748
  models: found.models,
10617
10749
  seats: found.seats,
10618
10750
  holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
10751
+ ...found.releaseSeat ? { releaseSeat: found.releaseSeat } : {},
10619
10752
  ...operator ? { operator } : {},
10620
10753
  ...contract ? { contract } : {},
10621
10754
  ...reviewedStatuses.length > 0 ? { reviewedStatuses } : {},
@@ -11264,6 +11397,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
11264
11397
  `);
11265
11398
  }
11266
11399
  }
11400
+ const missingAuthorship = await tracker.missingAuthorshipColumns();
11401
+ process.stdout.write(
11402
+ missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
11403
+ `
11404
+ );
11405
+ process.stdout.write(
11406
+ route.resolved?.releaseSeat ? "release seat: this ship has its own Release Crew row\n" : "release seat: none \u2014 release comments are credited to the dev seat; run `crew connect` once the Crew table has this ship's row\n"
11407
+ );
11267
11408
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
11268
11409
  if (ships.length === 0) {
11269
11410
  process.stdout.write("ship record: this workspace has no Ships table (fine)\n");
@@ -11339,6 +11480,20 @@ git: ${gitPath ? `OK (${gitPath})` : "NOT FOUND on the scheduler P
11339
11480
  process.stdout.write("host passengers: off (no enabled route sets hostPassengers: true)\n");
11340
11481
  } else {
11341
11482
  const dockerOk = dockerAvailable();
11483
+ const dockerPathIssue = dockerPathProblem(cfg.ship);
11484
+ const dockerKey = "docker_missing";
11485
+ if (dockerPathIssue) {
11486
+ process.stdout.write(`docker on scheduler PATH: NOT FOUND \u2014 ${dockerPathIssue}
11487
+ `);
11488
+ await raiseShipAttention({ state, emit, route: passengerRoutes[0], ship: cfg.ship }, {
11489
+ kind: "docker_missing",
11490
+ key: dockerKey,
11491
+ message: `Host Passengers is on but ${dockerPathIssue}`,
11492
+ since: (/* @__PURE__ */ new Date()).toISOString()
11493
+ });
11494
+ } else {
11495
+ clearShipAttention(state, dockerKey);
11496
+ }
11342
11497
  const keygenOk = sshKeygenAvailable();
11343
11498
  const image = passengerImageRef(cfg);
11344
11499
  const imageOk = dockerOk && imagePresent(image);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",
@@ -619,6 +619,11 @@ filed without them has nowhere to route to — it sits unclaimed by any
619
619
  per-repo queue until a person notices and fixes it by hand, however
620
620
  urgent its priority.
621
621
 
622
+ If that block also lists `filed_by_id` (your own Crew row) or `ship_id` (the
623
+ ship you are running on), set those too, and `ship_id` on every comment you
624
+ post. They record who filed the ticket and from where. The block lists a column
625
+ only when this workspace has it, so a column it does not name is not yours to set.
626
+
622
627
  Name the ticket you were working when you noticed it in the new ticket's
623
628
  description. Only put it in the new ticket's `blocked by` column if your
624
629
  own ticket genuinely cannot proceed without the new one being fixed
@@ -600,6 +600,11 @@ filed without them has nowhere to route to — it sits unclaimed by any
600
600
  per-repo queue until a person notices and fixes it by hand, however
601
601
  urgent its priority.
602
602
 
603
+ If that block also lists `filed_by_id` (your own Crew row) or `ship_id` (the
604
+ ship you are running on), set those too, and `ship_id` on every comment you
605
+ post. They record who filed the ticket and from where. The block lists a column
606
+ only when this workspace has it, so a column it does not name is not yours to set.
607
+
603
608
  Name the ticket you were working when you noticed it in the new ticket's
604
609
  description. Only put it in the new ticket's `blocked by` column if your
605
610
  own ticket genuinely cannot proceed without the new one being fixed