@tablation/crew 0.44.0 → 0.45.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
@@ -3234,8 +3234,6 @@ var DEFAULT_CONTRACT = {
3234
3234
  parent: "ticket_id",
3235
3235
  body: "body",
3236
3236
  author: "team_member_id",
3237
- kind: "kind",
3238
- eventKind: "event",
3239
3237
  createdAt: "created_at",
3240
3238
  ship: "ship_id"
3241
3239
  },
@@ -3345,6 +3343,7 @@ function validateContract(c) {
3345
3343
  }
3346
3344
  return problems;
3347
3345
  }
3346
+ var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
3348
3347
 
3349
3348
  // src/shell.ts
3350
3349
  import { spawn } from "child_process";
@@ -5209,18 +5208,65 @@ var Tracker = class {
5209
5208
  await this.client.records.update(this.models.crew, memberId, patch2);
5210
5209
  }
5211
5210
  /**
5212
- * An `event` comment — the runner's own audit trail, distinct from a
5213
- * seat's prose. `kind` is what keeps these out of the "new comment from
5214
- * someone else" wake signal.
5211
+ * One entry in the runner's audit trail, written to the Issues table's
5212
+ * event log (CREW-1574) rather than to Comments, so it can never be mistaken
5213
+ * for something a seat or person said. `type` is a short snake_case name
5214
+ * (`pushed`, `claimed`, `bounced`, …); `body` is the human message and keeps
5215
+ * whatever wording the caller already used. Attributed to the seat's Crew
5216
+ * row, with the posting ship in `metadata`.
5215
5217
  */
5216
- async postEvent(ticketId, body, memberId) {
5218
+ async postEvent(ticketId, body, memberId, type = "note") {
5217
5219
  const auth = await this.authorshipProbe();
5218
- await this.client.records.create(this.models.comments, {
5219
- ticket_id: ticketId,
5220
- body,
5221
- team_member_id: memberId,
5222
- kind: "event",
5223
- ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
5220
+ const res = await this.eventsFetch("POST", `/records/${ticketId}/events`, {
5221
+ type,
5222
+ message: body,
5223
+ actorRecordId: memberId,
5224
+ ...auth.shipId ? { metadata: { ship_id: auth.shipId } } : {}
5225
+ });
5226
+ if (res.status === 404) throw new ConfigError(EVENT_LOG_MISSING);
5227
+ if (!res.ok) throw new Error(`posting a ${type} event failed: HTTP ${res.status}`);
5228
+ }
5229
+ /**
5230
+ * Whether the Issues table has an event log: `GET /data-models/<issues>/events`
5231
+ * answers 200 when it does and 404 when the setting is off. Crew requires it
5232
+ * (CREW-1574); `connect` and doctor report `false` by name.
5233
+ */
5234
+ async hasEventLog() {
5235
+ const res = await this.eventsFetch("GET", "/events?limit=1");
5236
+ if (res.status === 404) return false;
5237
+ if (!res.ok) throw new Error(`reading the event log failed: HTTP ${res.status}`);
5238
+ return true;
5239
+ }
5240
+ /**
5241
+ * Events of `type` across the Issues table, newest first. The runner's own
5242
+ * dedupe/escalation markers (`stalled`, `gate_red`, `conflict_handed_back`,
5243
+ * `stranded_flagged`) live here now, so the readers that used to scan
5244
+ * Comments for them read this instead. A tracker without the log answers 404
5245
+ * and reads as no events: the writes fail by name (`postEvent`), and a
5246
+ * poll must not die on a read.
5247
+ */
5248
+ async events(type, limit = 500) {
5249
+ const filters = encodeURIComponent(JSON.stringify([{ columnName: "event_type", operator: "EQ", value: type }]));
5250
+ const res = await this.eventsFetch("GET", `/events?limit=${limit}&filters=${filters}`);
5251
+ if (res.status === 404) return [];
5252
+ if (!res.ok) throw new Error(`reading ${type} events failed: HTTP ${res.status}`);
5253
+ const rows = await res.json();
5254
+ return rows.filter((e) => e.event_type === type).sort((a, b) => Date.parse(b.occurred_at ?? b.created_at ?? "") - Date.parse(a.occurred_at ?? a.created_at ?? ""));
5255
+ }
5256
+ /** The newest event of `type` on a ticket, or undefined — e.g. the `pushed` tip QA verifies against. */
5257
+ async latestEvent(ticketId, type) {
5258
+ return (await this.events(type)).find((e) => e.record_id === ticketId);
5259
+ }
5260
+ /** The event-log routes hang off the Issues data model; @tablation/client has no resource for them yet. */
5261
+ eventsFetch(method, path, body) {
5262
+ return fetch(`${this.route.baseUrl}/api/data-models/${this.models.issues}${path}`, {
5263
+ method,
5264
+ headers: {
5265
+ Authorization: `Bearer ${resolveApiKey(this.route)}`,
5266
+ "User-Agent": this.userAgent,
5267
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
5268
+ },
5269
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
5224
5270
  });
5225
5271
  }
5226
5272
  /**
@@ -5495,7 +5541,7 @@ async function applySweep(writer, steps, memberId, log) {
5495
5541
  { ticket: step.ticket.issue_id, step: "sweep" }
5496
5542
  );
5497
5543
  try {
5498
- await writer.postEvent(step.ticket.id, sweepComment(step), memberId);
5544
+ await writer.postEvent(step.ticket.id, sweepComment(step), memberId, "blocked_sweep");
5499
5545
  } catch {
5500
5546
  log.warn("status changed but the note failed to post", { ticket: step.ticket.issue_id, step: "sweep" });
5501
5547
  }
@@ -5608,7 +5654,8 @@ async function noteReaccepted(tracker, result, seat, shipLabel) {
5608
5654
  await tracker.postEvent(
5609
5655
  result.ticket.id,
5610
5656
  `Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
5611
- seat
5657
+ seat,
5658
+ "reaccepted"
5612
5659
  );
5613
5660
  } catch {
5614
5661
  }
@@ -5641,7 +5688,8 @@ async function noteTakeover(tracker, result, seat, ships, myShipName, continuing
5641
5688
  await tracker.postEvent(
5642
5689
  result.ticket.id,
5643
5690
  `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."),
5644
- seat
5691
+ seat,
5692
+ "taken_over"
5645
5693
  );
5646
5694
  } catch {
5647
5695
  }
@@ -5711,7 +5759,7 @@ function buildingRoleHasWork(role, i) {
5711
5759
  );
5712
5760
  const watchedIds = new Set(watched.map((t) => t.id));
5713
5761
  const newComments = i.comments.filter(
5714
- (c) => c.created_at > i.watermark && c.team_member_id !== me && (c.kind ?? "comment") !== "event" && watchedIds.has(c.ticket_id)
5762
+ (c) => c.created_at > i.watermark && c.team_member_id !== me && watchedIds.has(c.ticket_id)
5715
5763
  ).length;
5716
5764
  const unassigned = mine.filter(
5717
5765
  (t) => t.status === "in_progress" && !t.assignee_id && !i.blocked.has(t.id)
@@ -5968,14 +6016,14 @@ function filedBy(t, i) {
5968
6016
  return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
5969
6017
  }
5970
6018
  function lastComment(i, ticketId) {
5971
- const forTicket = i.comments.filter((c) => c.ticket_id === ticketId && (c.kind ?? "comment") !== "event").sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
6019
+ 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);
5972
6020
  const last = forTicket[forTicket.length - 1];
5973
6021
  if (!last) return "\u2014";
5974
6022
  return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
5975
6023
  }
5976
6024
  function newFromOthers(i, ticketId) {
5977
6025
  return i.comments.filter(
5978
- (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark && (c.kind ?? "comment") !== "event"
6026
+ (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark
5979
6027
  ).length;
5980
6028
  }
5981
6029
  function who(t, i) {
@@ -6177,7 +6225,7 @@ var ms = (iso) => {
6177
6225
  const n = iso ? Date.parse(iso) : NaN;
6178
6226
  return Number.isFinite(n) ? n : 0;
6179
6227
  };
6180
- function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now(), wm) {
6228
+ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now(), wm, events = []) {
6181
6229
  const watched = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.handoff, contract.statuses.verifying]);
6182
6230
  const byTicket = /* @__PURE__ */ new Map();
6183
6231
  for (const c of comments) {
@@ -6190,7 +6238,7 @@ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ *
6190
6238
  if (!watched.has(t.status)) continue;
6191
6239
  if (t.assignee_id && holds2.has(t.assignee_id)) continue;
6192
6240
  const mine = byTicket.get(t.id) ?? [];
6193
- const lastMarker = Math.max(0, ...mine.filter((c) => (c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at)));
6241
+ const lastMarker = Math.max(0, ...events.filter((e) => e.record_id === t.id && e.event_type === "stalled").map((e) => ms(e.occurred_at ?? e.created_at ?? "")));
6194
6242
  const lastActivityAt = Math.max(
6195
6243
  ms(t.updated_at),
6196
6244
  ...mine.filter((c) => !(c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at))
@@ -6232,7 +6280,7 @@ async function applyStalled(writer, stalled, ships, memberId, myShipId, log, dry
6232
6280
  continue;
6233
6281
  }
6234
6282
  try {
6235
- await writer.postEvent(s.ticket.id, stalledComment(s, ships, now), memberId);
6283
+ await writer.postEvent(s.ticket.id, stalledComment(s, ships, now), memberId, "stalled");
6236
6284
  r.announced++;
6237
6285
  log.warn(`stalled at ${s.ticket.status} \u2014 ${s.holderShipId ? "flagged on the holder" : "commented, no holder"}`, x);
6238
6286
  } catch (e) {
@@ -6266,13 +6314,14 @@ async function decideCycle(o) {
6266
6314
  const { route, ship, state, emit } = o;
6267
6315
  const tracker = new Tracker(route, ship);
6268
6316
  emit.enter("poll");
6269
- const [tickets, comments, crewRows, epicRows, ships, projectModes] = await Promise.all([
6317
+ const [tickets, comments, crewRows, epicRows, ships, projectModes, stalledEvents] = await Promise.all([
6270
6318
  tracker.openTickets(),
6271
6319
  tracker.comments(200),
6272
6320
  tracker.crewRows(),
6273
6321
  tracker.epicRows(),
6274
6322
  tracker.shipRows(),
6275
- tracker.projectWorkModes()
6323
+ tracker.projectWorkModes(),
6324
+ tracker.events("stalled")
6276
6325
  ]);
6277
6326
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
6278
6327
  data: { tickets: tickets.length, comments: comments.length }
@@ -6316,7 +6365,7 @@ async function decideCycle(o) {
6316
6365
  }
6317
6366
  const sweep = planSweep(tickets, info, blocked, workModes);
6318
6367
  const holdReleases = planHoldReleases(tickets, tracker.contract);
6319
- const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2, Date.now(), workModes);
6368
+ const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2, Date.now(), workModes, stalledEvents);
6320
6369
  const skipped = skippedByMode(tickets, workModes);
6321
6370
  if (skipped.manual || skipped.hybrid) {
6322
6371
  emit.emit(`sweeps skipped ${skipped.manual} manual/project-less and ${skipped.hybrid} unassigned hybrid ticket(s)`, {
@@ -6602,15 +6651,15 @@ function worktreeForBranch(cwd, branch) {
6602
6651
  }
6603
6652
  return null;
6604
6653
  }
6605
- var priorBounces = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(BOUNCE_MARKER)).length;
6606
- function planConflictBounce(cwd, ticket, branch, base, paths, comments) {
6654
+ var priorBounces = (ticket, events) => events.filter((e) => e.record_id === ticket.id && e.event_type === "conflict_handed_back").length;
6655
+ function planConflictBounce(cwd, ticket, branch, base, paths, events) {
6607
6656
  return {
6608
6657
  ticket,
6609
6658
  branch,
6610
6659
  base,
6611
6660
  paths,
6612
6661
  worktree: worktreeForBranch(cwd, branch),
6613
- priorBounces: priorBounces(ticket, comments)
6662
+ priorBounces: priorBounces(ticket, events)
6614
6663
  };
6615
6664
  }
6616
6665
  function refreshBranch(worktree, base) {
@@ -6656,7 +6705,7 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
6656
6705
  }
6657
6706
  try {
6658
6707
  await writer.updateTicket(b.ticket.id, { status: contract.statuses.needsHuman }, b.ticket.updated_at);
6659
- await writer.postEvent(b.ticket.id, escalateComment(b), memberId).catch(() => {
6708
+ await writer.postEvent(b.ticket.id, escalateComment(b), memberId, "conflict_escalated").catch(() => {
6660
6709
  log.warn("escalated, but the note failed to post", t);
6661
6710
  });
6662
6711
  log.warn(`conflicts again after a hand-back \u2014 escalated to ${contract.statuses.needsHuman}`, t);
@@ -6693,7 +6742,7 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
6693
6742
  { status: contract.statuses.building, assignee_id: null },
6694
6743
  b.ticket.updated_at
6695
6744
  );
6696
- await writer.postEvent(b.ticket.id, handBackComment(b, merged), memberId).catch(() => {
6745
+ await writer.postEvent(b.ticket.id, handBackComment(b, merged), memberId, "conflict_handed_back").catch(() => {
6697
6746
  log.warn("handed back, but the note failed to post", t);
6698
6747
  });
6699
6748
  log.emit(
@@ -6772,7 +6821,7 @@ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRu
6772
6821
  { status: contract.statuses.building, assignee_id: null },
6773
6822
  b.ticket.updated_at
6774
6823
  );
6775
- await writer.postEvent(b.ticket.id, buildFailureComment(b), memberId).catch(() => {
6824
+ await writer.postEvent(b.ticket.id, buildFailureComment(b), memberId, "build_failed").catch(() => {
6776
6825
  log.warn("handed back, but the note failed to post", t);
6777
6826
  });
6778
6827
  log.emit(`handed back to ${contract.statuses.building} \u2014 breaks the ${b.hook} hook once merged with the rest`, t);
@@ -6789,14 +6838,14 @@ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRu
6789
6838
  // src/stranded-verified.ts
6790
6839
  var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
6791
6840
  var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
6792
- var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
6793
- function planStrandedVerified(candidate, base, remote, comments) {
6794
- const stamps = comments.filter((c) => c.ticket_id === candidate.ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).map((c) => Date.parse(c.created_at)).filter((n) => !Number.isNaN(n));
6841
+ var priorStrandedFlags = (ticket, events) => events.filter((e) => e.record_id === ticket.id && e.event_type === "stranded_flagged").length;
6842
+ function planStrandedVerified(candidate, base, remote, events) {
6843
+ const stamps = events.filter((e) => e.record_id === candidate.ticket.id && e.event_type === "stranded_flagged").map((e) => Date.parse(e.occurred_at ?? e.created_at ?? "")).filter((n) => !Number.isNaN(n));
6795
6844
  return {
6796
6845
  candidate,
6797
6846
  base,
6798
6847
  remote,
6799
- priorFlags: priorStrandedFlags(candidate.ticket, comments),
6848
+ priorFlags: priorStrandedFlags(candidate.ticket, events),
6800
6849
  lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
6801
6850
  };
6802
6851
  }
@@ -6834,7 +6883,7 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
6834
6883
  status: contract.statuses.needsHuman,
6835
6884
  ...operatorId ? { assignee_id: operatorId } : {}
6836
6885
  });
6837
- await writer.postEvent(s.candidate.ticket.id, escalateComment2(s), memberId).catch(() => {
6886
+ await writer.postEvent(s.candidate.ticket.id, escalateComment2(s), memberId, "stranded_escalated").catch(() => {
6838
6887
  log.warn("escalated, but the note failed to post", t);
6839
6888
  });
6840
6889
  log.warn(`still nothing to merge after a flag \u2014 escalated to ${contract.statuses.needsHuman}`, t);
@@ -6848,7 +6897,7 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
6848
6897
  return { kind: "flagged" };
6849
6898
  }
6850
6899
  try {
6851
- await writer.postEvent(s.candidate.ticket.id, flagComment(s), memberId).catch(() => {
6900
+ await writer.postEvent(s.candidate.ticket.id, flagComment(s), memberId, "stranded_flagged").catch(() => {
6852
6901
  log.warn("flagged, but the note failed to post", t);
6853
6902
  });
6854
6903
  log.emit("flagged as stranded at verified \u2014 will escalate if still unplaceable next cycle", t);
@@ -6883,7 +6932,7 @@ ${g.tail}
6883
6932
  \`\`\`
6884
6933
  `;
6885
6934
  }
6886
- var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
6935
+ var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
6887
6936
 
6888
6937
  // src/agent.ts
6889
6938
  import { spawn as spawn2 } from "child_process";
@@ -8495,7 +8544,7 @@ async function enforceHandoff(i) {
8495
8544
  return { kind: "unverified", why: v.why };
8496
8545
  }
8497
8546
  if (v.kind === "pushed") {
8498
- await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId);
8547
+ await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
8499
8548
  i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
8500
8549
  return { kind: "pushed", sha: v.sha };
8501
8550
  }
@@ -8505,7 +8554,7 @@ async function enforceHandoff(i) {
8505
8554
  ...t.assignee_id ? {} : { assignee_id: i.seatId },
8506
8555
  ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
8507
8556
  });
8508
- await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId).catch(() => {
8557
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
8509
8558
  i.log.warn("bounced, but the note failed to post", log);
8510
8559
  });
8511
8560
  i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
@@ -8563,7 +8612,7 @@ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2,
8563
8612
  if (o.kind === "removed") log.emit(`${t.issue_id}: removed the run's worktree ${o.path} \u2014 the branch is on ${cfg.branch.remote}`, at);
8564
8613
  else if (o.kind === "kept-handoff") {
8565
8614
  log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
8566
- await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId);
8615
+ await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId, "worktree_kept");
8567
8616
  } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
8568
8617
  else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
8569
8618
  } catch (e) {
@@ -9092,7 +9141,7 @@ ${h.prFailure.tail}
9092
9141
  [contract.columns.status]: reviewing,
9093
9142
  ...h.prRef && columns.prRef ? { [contract.columns.prRef]: h.prRef } : {}
9094
9143
  }, t.updated_at);
9095
- await tracker.postEvent(t.id, lines.join("\n\n"), seat);
9144
+ await tracker.postEvent(t.id, lines.join("\n\n"), seat, "review");
9096
9145
  emit.emit(`moved to ${reviewing}`, { ticket: t.issue_id });
9097
9146
  } catch (e) {
9098
9147
  emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
@@ -9107,7 +9156,7 @@ ${h.prFailure.tail}
9107
9156
  }
9108
9157
  try {
9109
9158
  await tracker.updateTicket(t.id, { [contract.columns.status]: contract.statuses.handoff }, t.updated_at);
9110
- await tracker.postEvent(t.id, body, seat);
9159
+ await tracker.postEvent(t.id, body, seat, "requeued");
9111
9160
  emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
9112
9161
  } catch (e) {
9113
9162
  emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
@@ -9936,7 +9985,7 @@ async function applyFailureAlert(writer, n, route, openTickets, memberId, log, d
9936
9985
  }
9937
9986
  try {
9938
9987
  await writer.postEvent(existing.id, `${FAILURE_ALERT_MARKER}
9939
- Still failing: ${n.detail ?? n.headline}`, memberId);
9988
+ Still failing: ${n.detail ?? n.headline}`, memberId, "failure_alert");
9940
9989
  log.warn(`release failure recurring \u2014 commented on ${label}`, { step: "release" });
9941
9990
  return { kind: "commented", issueId: label };
9942
9991
  } catch (e) {
@@ -10561,6 +10610,13 @@ async function discover(o) {
10561
10610
  } catch {
10562
10611
  }
10563
10612
  }
10613
+ if (out.models.issues) {
10614
+ try {
10615
+ await get(o, `/data-models/${out.models.issues}/events?limit=1`);
10616
+ } catch (e) {
10617
+ if (e instanceof ConnectHttpError && e.status === 404) problems.push(EVENT_LOG_MISSING);
10618
+ }
10619
+ }
10564
10620
  out.areaModelId = find("Projects");
10565
10621
  out.reposModelId = find("Repos");
10566
10622
  out.shipsModelId = find("Ships");
@@ -13105,11 +13161,11 @@ ${changed2.map((c) => ` - ${c}
13105
13161
  if (outcome.gateRed && !dryRun) {
13106
13162
  try {
13107
13163
  const g = outcome.gateRed;
13108
- const comments = await tracker.comments();
13164
+ const reported = await tracker.events("gate_red");
13109
13165
  const seat = releaseSeat(c);
13110
13166
  for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
13111
- if (alreadyReported(t, g, comments)) continue;
13112
- await tracker.postEvent(t.id, gateRedComment(g), seat);
13167
+ if (alreadyReported(t, g, reported)) continue;
13168
+ await tracker.postEvent(t.id, gateRedComment(g), seat, "gate_red");
13113
13169
  remit.warn(`test gate red for ${g.count} cycles \u2014 noted on the ticket`, { ticket: t.issue_id, step: "merge" });
13114
13170
  }
13115
13171
  state.release(scope).noteTestGateReported(g.sha);
@@ -13157,7 +13213,8 @@ ${changed2.map((c) => ` - ${c}
13157
13213
  }
13158
13214
  }
13159
13215
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
13160
- const comments = await tracker.comments();
13216
+ const handedBack = await tracker.events("conflict_handed_back");
13217
+ const flagged = await tracker.events("stranded_flagged");
13161
13218
  const seat = releaseSeat(c);
13162
13219
  for (const f of outcome.conflicts ?? []) {
13163
13220
  try {
@@ -13167,7 +13224,7 @@ ${changed2.map((c) => ` - ${c}
13167
13224
  f.candidate.branch,
13168
13225
  repo.branch.base,
13169
13226
  f.paths,
13170
- comments
13227
+ handedBack
13171
13228
  );
13172
13229
  const r = await applyConflictBounce(tracker, bounce, tracker.contract, seat, remit, dryRun);
13173
13230
  if (r.kind === "failed") {
@@ -13182,7 +13239,7 @@ ${changed2.map((c) => ` - ${c}
13182
13239
  }
13183
13240
  for (const cand of outcome.unbuildable ?? []) {
13184
13241
  try {
13185
- const stranded = planStrandedVerified(cand, repo.branch.base, repo.branch.remote, comments);
13242
+ const stranded = planStrandedVerified(cand, repo.branch.base, repo.branch.remote, flagged);
13186
13243
  const r = await applyStrandedVerified(
13187
13244
  tracker,
13188
13245
  stranded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.44.0",
3
+ "version": "0.45.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",
@@ -56,7 +56,7 @@ pass.
56
56
  4. **Get the branch and read the diff.** The branch on the remote is what
57
57
  you test, and you never need the builder's worktree: cut your own with
58
58
  `crew worktree <number>` (it prints the path), unless the builder's
59
- hand-off event says its ship kept a worktree and a server for you, in
59
+ hand-off event (in the ticket's event log) says its ship kept a worktree and a server for you, in
60
60
  which case you may open that URL instead. Remove yours when you hand the
61
61
  ticket on. The digest's `branch` column reads `<name> (on <remote> only)`
62
62
  when another ship built it — still testable; only **MISSING** (gone
@@ -65,7 +65,9 @@ pass.
65
65
 
66
66
  **Name the sha you test.** Your starting comment states the
67
67
  `<remote>/<branch>` tip you fetched. The runner records the pushed tip on
68
- the ticket as `pushed <sha> to <remote>/<branch>`; if your tip differs
68
+ the ticket's activity (its event log, not a comment) as a `pushed` event
69
+ reading `pushed <sha> to <remote>/<branch>`; read it with the events list
70
+ on the Issues table, filtered to the `pushed` type. If your tip differs
69
71
  from the latest such event, say so and verify the remote tip anyway — the
70
72
  remote is the only git truth.
71
73
 
@@ -363,7 +363,7 @@ before going further, and never change a ticket's `report_type` yourself.
363
363
  `fixed` and `<remote>/<branch>` is missing or not at your worktree's
364
364
  HEAD, it sets the ticket back to `in_progress` with an event saying what
365
365
  it found, and QA never sees it. When it matches, the runner records
366
- `pushed <sha> to <remote>/<branch>` on the ticket; you still state the sha
366
+ `pushed <sha> to <remote>/<branch>` in the ticket's activity (its event log); you still state the sha
367
367
  in your last progress comment.
368
368
 
369
369
  **The branch on the remote is what QA tests.** Leave it unmerged, and do
@@ -55,7 +55,7 @@ pass.
55
55
  4. **Get the branch and read the diff.** The branch on the remote is what
56
56
  you test, and you never need the builder's worktree: cut your own with
57
57
  `crew worktree <number>` (it prints the path), unless the builder's
58
- hand-off event says its ship kept a worktree and a server for you, in
58
+ hand-off event (in the ticket's event log) says its ship kept a worktree and a server for you, in
59
59
  which case you may open that URL instead. Remove yours when you hand the
60
60
  ticket on. The digest's `branch` column reads `<name> (on <remote> only)`
61
61
  when another ship built it — still testable; only **MISSING** (gone
@@ -64,7 +64,9 @@ pass.
64
64
 
65
65
  **Name the sha you test.** Your starting comment states the
66
66
  `<remote>/<branch>` tip you fetched. The runner records the pushed tip on
67
- the ticket as `pushed <sha> to <remote>/<branch>`; if your tip differs
67
+ the ticket's activity (its event log, not a comment) as a `pushed` event
68
+ reading `pushed <sha> to <remote>/<branch>`; read it with the events list
69
+ on the Issues table, filtered to the `pushed` type. If your tip differs
68
70
  from the latest such event, say so and verify the remote tip anyway — the
69
71
  remote is the only git truth.
70
72
 
@@ -363,7 +363,7 @@ before going further, and never change a ticket's `report_type` yourself.
363
363
  `fixed` and `<remote>/<branch>` is missing or not at your worktree's
364
364
  HEAD, it sets the ticket back to `in_progress` with an event saying what
365
365
  it found, and QA never sees it. When it matches, the runner records
366
- `pushed <sha> to <remote>/<branch>` on the ticket; you still state the sha
366
+ `pushed <sha> to <remote>/<branch>` in the ticket's activity (its event log); you still state the sha
367
367
  in your last progress comment.
368
368
 
369
369
  **The branch on the remote is what QA tests.** Leave it unmerged, and do