@tablation/crew 0.6.0 → 0.7.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/README.md CHANGED
@@ -207,7 +207,7 @@ crew ports [route] which checkout owns which ports, and what is up
207
207
  crew reap [route] kill servers left behind by removed worktrees
208
208
  crew drop [route] NNN remove a merged ticket's worktree and branch
209
209
  crew sync [route] fast-forward the checkout and its worktrees from the remote
210
- crew pause|resume [route] [R] pause everything, or one role
210
+ crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
211
211
  crew log [route] tail the log
212
212
  crew inbox [--member NAME] your tickets across every workspace
213
213
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
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\``);
@@ -1219,6 +1224,31 @@ var State = class _State {
1219
1224
  resume(role) {
1220
1225
  rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1221
1226
  }
1227
+ /**
1228
+ * The release phase (merge, deploy, release) has its own sentinel, one
1229
+ * level below the whole-crew pause: `crew pause release` stops shipping
1230
+ * while agents keep working.
1231
+ */
1232
+ isReleasePaused() {
1233
+ return existsSync3(this.path(".release-paused"));
1234
+ }
1235
+ setReleasePaused(paused) {
1236
+ if (paused) writeFileSync(this.path(".release-paused"), "");
1237
+ else rmSync(this.path(".release-paused"), { force: true });
1238
+ }
1239
+ /**
1240
+ * Why a release cycle must not run right now, or null when it may. Every
1241
+ * release entry point checks this first — the release timer, `crew release`,
1242
+ * `crew merge`, `crew deploy` and the inline release a `run` cycle used to
1243
+ * do — so a paused ship cannot merge, deploy, or file release alerts.
1244
+ * A release already in flight when the pause lands finishes; only new
1245
+ * cycles are refused.
1246
+ */
1247
+ releaseSkipReason() {
1248
+ if (this.isPaused()) return "the crew is paused";
1249
+ if (this.isReleasePaused()) return "releases are paused";
1250
+ return null;
1251
+ }
1222
1252
  /**
1223
1253
  * Take an exclusive lock, or report who holds it.
1224
1254
  *
@@ -1650,7 +1680,9 @@ var DEFAULT_CONTRACT = {
1650
1680
  updatedAt: "updated_at",
1651
1681
  needsDesign: "needs_design",
1652
1682
  reportType: "report_type",
1653
- epic: "epic_id"
1683
+ epic: "epic_id",
1684
+ author: "filed_by_id",
1685
+ ship: "ship_id"
1654
1686
  },
1655
1687
  comments: {
1656
1688
  parent: "ticket_id",
@@ -1658,7 +1690,8 @@ var DEFAULT_CONTRACT = {
1658
1690
  author: "team_member_id",
1659
1691
  kind: "kind",
1660
1692
  eventKind: "event",
1661
- createdAt: "created_at"
1693
+ createdAt: "created_at",
1694
+ ship: "ship_id"
1662
1695
  },
1663
1696
  statuses: {
1664
1697
  open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
@@ -2446,6 +2479,12 @@ var Tracker = class {
2446
2479
  client;
2447
2480
  models;
2448
2481
  userAgent;
2482
+ shipName;
2483
+ /**
2484
+ * The once-per-process authorship probe (CREW-1371), memoised as a promise
2485
+ * so concurrent first writes share one round of field-metadata reads.
2486
+ */
2487
+ authorship;
2449
2488
  stateDir;
2450
2489
  constructor(route, ship) {
2451
2490
  this.route = route;
@@ -2457,6 +2496,7 @@ var Tracker = class {
2457
2496
  this.models = route.resolved.models;
2458
2497
  this.contract = resolveContract(route.contract);
2459
2498
  this.userAgent = ship.userAgent;
2499
+ this.shipName = ship.name;
2460
2500
  this.stateDir = ship.stateDir;
2461
2501
  this.client = new TablationClient({
2462
2502
  baseUrl: `${route.baseUrl}/api`,
@@ -2809,13 +2849,60 @@ var Tracker = class {
2809
2849
  * someone else" wake signal.
2810
2850
  */
2811
2851
  async postEvent(ticketId, body, memberId) {
2852
+ const auth = await this.authorshipProbe();
2812
2853
  await this.client.records.create(this.models.comments, {
2813
2854
  ticket_id: ticketId,
2814
2855
  body,
2815
2856
  team_member_id: memberId,
2816
- kind: "event"
2857
+ kind: "event",
2858
+ ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
2817
2859
  });
2818
2860
  }
2861
+ /**
2862
+ * The authorship probe (CREW-1371), read once per process: which optional
2863
+ * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
2864
+ * ship's own Ships row id. Memoised as a promise so concurrent first writes
2865
+ * share one round of metadata reads. A table whose metadata cannot be read
2866
+ * reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
2867
+ */
2868
+ authorshipProbe() {
2869
+ this.authorship ??= this.probeAuthorship();
2870
+ return this.authorship;
2871
+ }
2872
+ async probeAuthorship() {
2873
+ const [issueFields, commentFields, shipRow] = await Promise.all([
2874
+ this.columnNames(this.models.issues),
2875
+ this.columnNames(this.models.comments),
2876
+ // No name (a caller that only beats or patches, never stamps) means no row to find.
2877
+ this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
2878
+ ]);
2879
+ return {
2880
+ issueAuthor: issueFields.has(this.contract.columns.author),
2881
+ issueShip: issueFields.has(this.contract.columns.ship),
2882
+ commentShip: commentFields.has(this.contract.comments.ship),
2883
+ shipId: shipRow?.id ?? null
2884
+ };
2885
+ }
2886
+ async columnNames(modelId) {
2887
+ try {
2888
+ const model = await this.client.dataModels.get(modelId);
2889
+ return new Set(model.fields.map((f) => f.columnName));
2890
+ } catch {
2891
+ return /* @__PURE__ */ new Set();
2892
+ }
2893
+ }
2894
+ /**
2895
+ * The authorship columns this workspace is missing, as `table.column` names
2896
+ * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
2897
+ */
2898
+ async missingAuthorshipColumns() {
2899
+ const auth = await this.authorshipProbe();
2900
+ const missing = [];
2901
+ if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
2902
+ if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
2903
+ if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
2904
+ return missing;
2905
+ }
2819
2906
  /**
2820
2907
  * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
2821
2908
  * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
@@ -2857,8 +2944,13 @@ var Tracker = class {
2857
2944
  * not among them (`postEvent` above hardcodes its own fields the same
2858
2945
  * way).
2859
2946
  */
2860
- async fileTicket(fields) {
2861
- return this.client.records.create(this.models.issues, fields);
2947
+ async fileTicket(fields, memberId) {
2948
+ const auth = await this.authorshipProbe();
2949
+ return this.client.records.create(this.models.issues, {
2950
+ ...fields,
2951
+ ...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
2952
+ ...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
2953
+ });
2862
2954
  }
2863
2955
  };
2864
2956
 
@@ -2908,7 +3000,8 @@ function rosterMarkdown(roster, meId) {
2908
3000
  `You are **${selfName}**. Address your shipmates by name in ticket`,
2909
3001
  "comments the way you would a colleague; add the role in parentheses only",
2910
3002
  "where a reader would otherwise not know which seat you mean. Your own",
2911
- "row id is what you write to `assignee_id` and `team_member_id`.",
3003
+ "row id is what you write to `assignee_id` and `team_member_id`, and to",
3004
+ "`filed_by_id` on a ticket you file (where the Environment section names it).",
2912
3005
  "",
2913
3006
  "| seat | name | Crew row id |",
2914
3007
  "|---|---|---|"
@@ -3365,20 +3458,36 @@ function attentionTransitions(previous, current) {
3365
3458
  function displayKey2(t) {
3366
3459
  return t.issue_tag ?? t.issue_id ?? "?";
3367
3460
  }
3368
- function author(c, me, roster) {
3461
+ function author(c, i) {
3462
+ const { me, roster } = i;
3369
3463
  if (c.team_member_id === me) return "you";
3370
3464
  if (c.team_member_id === null || c.team_member_id === void 0) {
3371
3465
  const byIdentity = memberByIdentity(roster, c.created_by_id);
3372
3466
  if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
3373
3467
  return `${c.reporter_name ?? "anon"} (no identity)`;
3374
3468
  }
3375
- return crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship";
3469
+ return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
3470
+ }
3471
+ function shipSuffix(c, i) {
3472
+ if (!c.ship_id || c.ship_id === i.myShipId) return "";
3473
+ const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
3474
+ return name ? ` (${name})` : "";
3475
+ }
3476
+ function filedBy(t, i) {
3477
+ const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
3478
+ if (member) {
3479
+ if (member.id === i.me) return "you";
3480
+ const label = crewLabel(member) ?? member.name;
3481
+ const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
3482
+ return ship ? `${label} (${ship})` : label;
3483
+ }
3484
+ return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
3376
3485
  }
3377
3486
  function lastComment(i, ticketId) {
3378
3487
  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);
3379
3488
  const last = forTicket[forTicket.length - 1];
3380
3489
  if (!last) return "\u2014";
3381
- return `${last.created_at.slice(0, 16)}Z ${author(last, i.me, i.roster)}`;
3490
+ return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
3382
3491
  }
3383
3492
  function newFromOthers(i, ticketId) {
3384
3493
  return i.comments.filter(
@@ -3410,14 +3519,14 @@ ${sorted(ts).map(row).join("\n")}
3410
3519
  `;
3411
3520
  }
3412
3521
  function buildingDigest(i) {
3413
- const header = "| ticket | repo | branch | worktree | status | assignee | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|";
3522
+ const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3414
3523
  const row = (t) => {
3415
3524
  const n = newFromOthers(i, t.id);
3416
3525
  const branch = i.branchFor ? i.branchFor(t) : "";
3417
3526
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
3418
3527
  const dir = i.dirFor ? i.dirFor(t) : null;
3419
3528
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3420
- 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"} |`;
3529
+ 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"} |`;
3421
3530
  };
3422
3531
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
3423
3532
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -3449,7 +3558,7 @@ ${sorted(ts).map(blockedRow).join("\n")}
3449
3558
  ]);
3450
3559
  }
3451
3560
  function qaDigest(i) {
3452
- const header = "| ticket | repo | status | built by | assignee | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3561
+ const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3453
3562
  const branchCell = (t) => {
3454
3563
  if (i.dirFor && i.dirFor(t) === null) return "\u2014";
3455
3564
  if (!i.existingBranchFor) return "";
@@ -3464,7 +3573,7 @@ function qaDigest(i) {
3464
3573
  const n = newFromOthers(i, t.id);
3465
3574
  const dir = i.dirFor ? i.dirFor(t) : null;
3466
3575
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3467
- 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"} |`;
3576
+ 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"} |`;
3468
3577
  };
3469
3578
  return stream([
3470
3579
  "## Current queue \u2014 built for you by the poll\n",
@@ -3927,11 +4036,12 @@ async function decideCycle(o) {
3927
4036
  const { route, ship, state, emit } = o;
3928
4037
  const tracker = new Tracker(route, ship);
3929
4038
  emit.enter("poll");
3930
- const [tickets, comments, crewRows, epicRows] = await Promise.all([
4039
+ const [tickets, comments, crewRows, epicRows, ships] = await Promise.all([
3931
4040
  tracker.openTickets(),
3932
4041
  tracker.comments(200),
3933
4042
  tracker.crewRows(),
3934
- tracker.epicRows()
4043
+ tracker.epicRows(),
4044
+ tracker.shipRows()
3935
4045
  ]);
3936
4046
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
3937
4047
  data: { tickets: tickets.length, comments: comments.length }
@@ -4044,6 +4154,7 @@ async function decideCycle(o) {
4044
4154
  tickets,
4045
4155
  comments,
4046
4156
  roster,
4157
+ ships,
4047
4158
  blocked,
4048
4159
  info,
4049
4160
  sweep,
@@ -4122,6 +4233,10 @@ function writeDigest(o, d, role, stateDir) {
4122
4233
  comments: d.comments,
4123
4234
  me,
4124
4235
  roster: d.roster,
4236
+ // CREW-1371: a comment from another ship is labelled with that ship's
4237
+ // name, so two ships under one API key are distinguishable on the queue.
4238
+ ships: d.ships,
4239
+ myShipId: d.ships.find((s) => (s.name ?? "").trim() === o.ship.name.trim())?.id ?? null,
4125
4240
  watermark: d.watermark,
4126
4241
  blocked: d.blocked,
4127
4242
  blockerInfo: d.info
@@ -4397,6 +4512,14 @@ import { createHash as createHash2 } from "crypto";
4397
4512
  // src/environment.ts
4398
4513
  import { basename as basename4 } from "path";
4399
4514
  var API_KEY_VAR = "CREW_API_KEY";
4515
+ function authorshipBullets(a, contract) {
4516
+ if (!a) return [];
4517
+ return [
4518
+ ...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
4519
+ ...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
4520
+ ...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
4521
+ ];
4522
+ }
4400
4523
  var HOOK_PURPOSE = [
4401
4524
  ["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
4402
4525
  ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
@@ -4603,6 +4726,7 @@ function renderEnvironment(i) {
4603
4726
  ...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
4604
4727
  (r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
4605
4728
  ),
4729
+ ...authorshipBullets(i.authorship, contract),
4606
4730
  "",
4607
4731
  ...i.sourceTicket ? [
4608
4732
  `Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
@@ -6103,7 +6227,7 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
6103
6227
  report_type: "bug",
6104
6228
  severity: "s2",
6105
6229
  reporter_name: "Crew release"
6106
- });
6230
+ }, memberId);
6107
6231
  const label = created.issue_id ? displayKey(created) : created.id;
6108
6232
  log.warn(`release failure \u2014 filed ${label}`, { step: "release" });
6109
6233
  return { kind: "filed", issueId: label };
@@ -7133,7 +7257,19 @@ async function discover(o) {
7133
7257
  }
7134
7258
  const pairId = seatFor(scoped, "pair") || (shipRowId ? seatFor(unclaimed, "pair") : void 0);
7135
7259
  if (pairId) out.seats.pair = pairId;
7136
- const seatIds = new Set(Object.values(out.seats));
7260
+ if (shipRowId) {
7261
+ const releaseId = seatFor(scoped, "release");
7262
+ if (releaseId) out.releaseSeat = releaseId;
7263
+ else {
7264
+ const created = await post2(o, `/data-models/${out.models.crew}/records`, {
7265
+ name: "Release agent",
7266
+ ship_id: shipRowId
7267
+ });
7268
+ out.releaseSeat = created.id;
7269
+ provisioning.push("created Crew row for the release seat");
7270
+ }
7271
+ }
7272
+ const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
7137
7273
  out.holds = rows.filter((r) => !seatIds.has(r.id)).map((r) => ({ id: r.id, name: r.name ?? "" }));
7138
7274
  if (out.holds.length === 0) {
7139
7275
  problems.push("no non-seat Crew rows \u2014 which row is the operator?");
@@ -8525,6 +8661,7 @@ var SpawnBreaker = class {
8525
8661
  }
8526
8662
  };
8527
8663
  async function runOnePass(o) {
8664
+ if (o.state.isPaused()) return { pending: [], started: [], skipped: [] };
8528
8665
  await hydrateApiKeys([o.route], o.getSessionStore);
8529
8666
  const emit = o.newEmitter();
8530
8667
  const decide = o.decide ?? decideCycle;
@@ -8706,7 +8843,8 @@ async function runRoleAgent(role, decision, ctx) {
8706
8843
  }
8707
8844
  }
8708
8845
  async function buildEnvironment(route, ship, ticket) {
8709
- const contract = new Tracker(route, ship).contract;
8846
+ const tracker = new Tracker(route, ship);
8847
+ const contract = tracker.contract;
8710
8848
  const repos = reposOf(route).map((t) => {
8711
8849
  const id = repoIdForName(route, t.name);
8712
8850
  const o = route.repoOverrides[t.name];
@@ -8721,7 +8859,8 @@ async function buildEnvironment(route, ship, ticket) {
8721
8859
  }, t.dir)
8722
8860
  };
8723
8861
  });
8724
- return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket });
8862
+ const authorship = await tracker.authorshipProbe().catch(() => null);
8863
+ return renderEnvironment({ route, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship });
8725
8864
  }
8726
8865
 
8727
8866
  // src/cli.ts
@@ -8852,7 +8991,8 @@ function usage() {
8852
8991
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
8853
8992
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
8854
8993
  crew sync [route] fast-forward the checkout and its worktrees from the remote
8855
- crew pause|resume [route] [R] pause everything, or one role
8994
+ crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
8995
+ resume with no argument clears only the whole-crew pause; a role or release pause needs its own resume
8856
8996
  crew log [route] tail the log
8857
8997
  crew inbox [--member NAME] your tickets across every workspace (or a colleague's)
8858
8998
  crew connect resolve a workspace's ids into a crew.yaml block
@@ -8953,6 +9093,11 @@ if (command === "passenger-sync-daemon") {
8953
9093
  urlFor: (slug) => publicUrlFor(slug, relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain),
8954
9094
  warn: (m) => emit.warn(m, { step: "passengers" })
8955
9095
  };
9096
+ }, releaseRefused = function() {
9097
+ const why = state.releaseSkipReason();
9098
+ if (!why) return false;
9099
+ emit.emit(`${why} \u2014 release skipped`, { step: "release" });
9100
+ return true;
8956
9101
  }, reportUnplaceable = function(c, unplaceable, verified, emit2) {
8957
9102
  if (unplaceableReported.has(c.route)) return;
8958
9103
  unplaceableReported.add(c.route);
@@ -8961,7 +9106,7 @@ if (command === "passenger-sync-daemon") {
8961
9106
  else emit2.emit(note.message, { step: "release" });
8962
9107
  }
8963
9108
  };
8964
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, reportUnplaceable2 = reportUnplaceable;
9109
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, reportUnplaceable2 = reportUnplaceable;
8965
9110
  let cfg;
8966
9111
  let firstRun;
8967
9112
  try {
@@ -9003,7 +9148,7 @@ if (command === "passenger-sync-daemon") {
9003
9148
  const releaseFleetWide = ["merge", "deploy", "release"].includes(command) && flag("fleet");
9004
9149
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
9005
9150
  const named = command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
9006
- const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
9151
+ const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || command === "pause" || command === "resume" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
9007
9152
  let route;
9008
9153
  try {
9009
9154
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -9093,6 +9238,7 @@ if (command === "passenger-sync-daemon") {
9093
9238
  }
9094
9239
  }
9095
9240
  async function releaseFleet(opts = {}) {
9241
+ if (releaseRefused()) return;
9096
9242
  const summaries = [];
9097
9243
  if (!dryRun) state.releaseHeartbeat().touch();
9098
9244
  for (const c of cfg.routes) {
@@ -9103,6 +9249,7 @@ if (command === "passenger-sync-daemon") {
9103
9249
  if (!dryRun) state.releaseHeartbeat().touch();
9104
9250
  }
9105
9251
  async function releaseTargets(opts = {}) {
9252
+ if (releaseRefused()) return;
9106
9253
  const only = value("repo");
9107
9254
  const targets = reposOf(route);
9108
9255
  const chosen = only ? targets.filter((t) => t.name === only) : targets;
@@ -9302,13 +9449,15 @@ ${changed2.map((c) => ` - ${c}
9302
9449
  return (await resolvedRepos(c)).map((r) => `${r.name} requires ${r.config.platform}`).join(", ");
9303
9450
  }
9304
9451
  async function environmentFor(c, ticket) {
9305
- const contract = new Tracker(c, cfg.ship).contract;
9452
+ const tracker = new Tracker(c, cfg.ship);
9453
+ const contract = tracker.contract;
9306
9454
  return renderEnvironment({
9307
9455
  route: c,
9308
9456
  userAgent: cfg.ship.userAgent,
9309
9457
  repos: await resolvedRepos(c),
9310
9458
  contract,
9311
- sourceTicket: ticket
9459
+ sourceTicket: ticket,
9460
+ authorship: await tracker.authorshipProbe().catch(() => null)
9312
9461
  });
9313
9462
  }
9314
9463
  const unplaceableReported = /* @__PURE__ */ new Set();
@@ -9410,7 +9559,7 @@ ${changed2.map((c) => ` - ${c}
9410
9559
  try {
9411
9560
  const g = outcome.gateRed;
9412
9561
  const comments = await tracker.comments();
9413
- const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9562
+ const seat = releaseSeat(c);
9414
9563
  for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
9415
9564
  if (alreadyReported(t, g, comments)) continue;
9416
9565
  await tracker.postEvent(t.id, gateRedComment(g), seat);
@@ -9428,7 +9577,7 @@ ${changed2.map((c) => ` - ${c}
9428
9577
  if (news) {
9429
9578
  const notified = await notify(c, cfg.ship, news, remit, dryRun);
9430
9579
  if (!notified && news.level === "fail") {
9431
- const memberId = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9580
+ const memberId = releaseSeat(c);
9432
9581
  await applyFailureAlert(tracker, news, scope, all, memberId, remit, dryRun).catch((e) => {
9433
9582
  remit.warn(`could not file/update a failure ticket: ${e.message}`, { step: "release" });
9434
9583
  });
@@ -9436,7 +9585,7 @@ ${changed2.map((c) => ` - ${c}
9436
9585
  }
9437
9586
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
9438
9587
  const comments = await tracker.comments();
9439
- const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9588
+ const seat = releaseSeat(c);
9440
9589
  for (const f of outcome.conflicts ?? []) {
9441
9590
  try {
9442
9591
  const bounce = planConflictBounce(
@@ -10582,6 +10731,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10582
10731
  models: found.models,
10583
10732
  seats: found.seats,
10584
10733
  holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
10734
+ ...found.releaseSeat ? { releaseSeat: found.releaseSeat } : {},
10585
10735
  ...operator ? { operator } : {},
10586
10736
  ...contract ? { contract } : {},
10587
10737
  ...reviewedStatuses.length > 0 ? { reviewedStatuses } : {},
@@ -11022,6 +11172,12 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11022
11172
  }
11023
11173
  case "pause":
11024
11174
  case "resume": {
11175
+ if (positional[1] === "release" || positional[2] === "release") {
11176
+ state.setReleasePaused(command === "pause");
11177
+ process.stdout.write(`${command}d releases
11178
+ `);
11179
+ break;
11180
+ }
11025
11181
  const role = positional[2] ?? (["dev", "design", "qa"].includes(positional[1] ?? "") ? positional[1] : void 0);
11026
11182
  if (command === "pause") state.pause(role);
11027
11183
  else state.resume(role);
@@ -11113,6 +11269,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11113
11269
  },
11114
11270
  crew: {
11115
11271
  paused: state.isPaused(),
11272
+ // The release timer honors the whole-crew pause too, so this is
11273
+ // true whenever releases are stopped for either reason.
11274
+ releasePaused: state.releaseSkipReason() !== null,
11116
11275
  roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
11117
11276
  seats: seats2
11118
11277
  },
@@ -11156,6 +11315,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11156
11315
  route: ${route.route} -> ${route.dir}
11157
11316
  enabled: ${route.enabled}
11158
11317
  crew: ${state.isPaused() ? "paused" : "active"}
11318
+ release: ${state.releaseSkipReason() ? "paused" : "active"}
11159
11319
  watermark: ${state.watermark()}
11160
11320
  `
11161
11321
  );
@@ -11220,6 +11380,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
11220
11380
  `);
11221
11381
  }
11222
11382
  }
11383
+ const missingAuthorship = await tracker.missingAuthorshipColumns();
11384
+ process.stdout.write(
11385
+ missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
11386
+ `
11387
+ );
11388
+ process.stdout.write(
11389
+ 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"
11390
+ );
11223
11391
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
11224
11392
  if (ships.length === 0) {
11225
11393
  process.stdout.write("ship record: this workspace has no Ships table (fine)\n");
@@ -11357,4 +11525,5 @@ var parseTicketNumber2;
11357
11525
  var passengerApiKeys2;
11358
11526
  var endpointApiFor2;
11359
11527
  var endpointReconcileDeps2;
11528
+ var releaseRefused2;
11360
11529
  var reportUnplaceable2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -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