@tablation/crew 0.16.0 → 0.17.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve4, dirname as dirname10, join as join19, basename as basename7 } from "path";
4
+ import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename7 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
@@ -1796,7 +1796,9 @@ var DEFAULT_CONTRACT = {
1796
1796
  epic: "epic_id",
1797
1797
  author: "filed_by_id",
1798
1798
  ship: "ship_id",
1799
- heldBy: "held_by_ship_id"
1799
+ heldBy: "held_by_ship_id",
1800
+ verifiedSha: "verified_sha",
1801
+ prRef: "pr_ref"
1800
1802
  },
1801
1803
  comments: {
1802
1804
  parent: "ticket_id",
@@ -2782,7 +2784,8 @@ async function detectClosure(o) {
2782
2784
  const code = await o.mergedHook({
2783
2785
  CREW_TICKET: o.key,
2784
2786
  CREW_BRANCH: o.pushedBranch,
2785
- CREW_BASE: base
2787
+ CREW_BASE: base,
2788
+ ...o.pr ? { CREW_PR: o.pr } : {}
2786
2789
  });
2787
2790
  if (code === 0) {
2788
2791
  const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
@@ -2952,8 +2955,8 @@ var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
2952
2955
  function checkoutLockPath(dir) {
2953
2956
  return `${dir}.sync.lock`;
2954
2957
  }
2955
- function sleepSync(ms) {
2956
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2958
+ function sleepSync(ms2) {
2959
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
2957
2960
  }
2958
2961
  function acquireCheckoutLock(dir, timeoutMs = 3e4) {
2959
2962
  const lockPath = checkoutLockPath(dir);
@@ -3046,6 +3049,29 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
3046
3049
  if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
3047
3050
  return null;
3048
3051
  }
3052
+ function repoStopFor(items, route) {
3053
+ const stops = new Map(items.filter((i) => i.kind === "base_unsafe").map((i) => [i.key, i.message]));
3054
+ if (stops.size === 0) return () => null;
3055
+ return (t) => {
3056
+ const name = repoTargetFor(route, t.repo_id)?.name;
3057
+ return name ? stops.get(baseUnsafeKey(name)) ?? null : null;
3058
+ };
3059
+ }
3060
+ function recheckBaseStops(state, route) {
3061
+ const open = state.shipAttention().previous().filter((i) => i.kind === "base_unsafe");
3062
+ const cleared = [];
3063
+ for (const item of open) {
3064
+ const target = reposOf(route).find((t) => baseUnsafeKey(t.name) === item.key);
3065
+ if (!target) continue;
3066
+ const cfg = resolveRepoConfig(loadRepoConfig(target.dir), void 0, target.dir);
3067
+ const outcome = refreshBaseBranch(target.dir, cfg.branch.remote, cfg.branch.base, true);
3068
+ if (outcome.action === "level" || outcome.action === "would-fast-forward") {
3069
+ clearShipAttention(state, item.key);
3070
+ cleared.push(target.name);
3071
+ }
3072
+ }
3073
+ return cleared;
3074
+ }
3049
3075
  function openShipAttention(state, nowMs = Date.now()) {
3050
3076
  const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
3051
3077
  const stored = state.shipAttention().previous();
@@ -3122,7 +3148,7 @@ function releaseLockScope(route, repo) {
3122
3148
  }
3123
3149
  async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
3124
3150
  if (!modelId) return { ok: true, release: async () => {
3125
- } };
3151
+ }, exclusion: "off" };
3126
3152
  const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
3127
3153
  const rows = await client2.list(modelId, { filters, limit: 1 });
3128
3154
  const row = rows[0];
@@ -3139,6 +3165,7 @@ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
3139
3165
  }
3140
3166
  return {
3141
3167
  ok: true,
3168
+ exclusion: "taken",
3142
3169
  release: async () => {
3143
3170
  try {
3144
3171
  await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
@@ -3605,6 +3632,18 @@ var Tracker = class {
3605
3632
  shipId: auth.shipId
3606
3633
  };
3607
3634
  }
3635
+ /**
3636
+ * Which of the optional review columns (`verified_sha`, `pr_ref`) the Issues
3637
+ * table carries. A table without them has not adopted the review hand-off:
3638
+ * the crew then pushes and moves the ticket without recording either.
3639
+ */
3640
+ async reviewColumns() {
3641
+ const fields = await this.columnNames(this.models.issues);
3642
+ return {
3643
+ verifiedSha: fields.has(this.contract.columns.verifiedSha),
3644
+ prRef: fields.has(this.contract.columns.prRef)
3645
+ };
3646
+ }
3608
3647
  /**
3609
3648
  * The authorship probe (CREW-1371), read once per process: which optional
3610
3649
  * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
@@ -4005,6 +4044,10 @@ function withHoldCheck(affinity, i) {
4005
4044
  const a = affinityContext(i);
4006
4045
  return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
4007
4046
  }
4047
+ function notInStoppedRepo(slice, i) {
4048
+ const stop = i.repoStop;
4049
+ return stop ? slice.filter((t) => stop(t) === null) : slice;
4050
+ }
4008
4051
  function notHeldByOtherShip(slice, i) {
4009
4052
  if (!i.ships) return slice;
4010
4053
  const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
@@ -4013,7 +4056,7 @@ function notHeldByOtherShip(slice, i) {
4013
4056
  function buildingRoleHasWork(role, i) {
4014
4057
  const me = i.seats[role];
4015
4058
  if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
4016
- const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
4059
+ const mine = notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i);
4017
4060
  const startable = mine.filter(
4018
4061
  (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
4019
4062
  );
@@ -4063,7 +4106,7 @@ function roleCandidates(role, i) {
4063
4106
  if (role === "pair") return [];
4064
4107
  const me = i.seats[role];
4065
4108
  if (!me) return [];
4066
- return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
4109
+ return notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i).filter(
4067
4110
  (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)
4068
4111
  );
4069
4112
  }
@@ -4235,6 +4278,16 @@ function attentionTransitions(previous, current) {
4235
4278
  }
4236
4279
  return out;
4237
4280
  }
4281
+ function operatorTodo(tickets, contract, operatorId) {
4282
+ const out = [];
4283
+ for (const ticket of tickets) {
4284
+ const reasons = attentionReasons(ticket, contract).filter(
4285
+ (r) => r === "needs_planning" || r === "needs_info" && ticket.assignee_id === operatorId
4286
+ );
4287
+ if (reasons.length > 0) out.push({ ticket, reasons });
4288
+ }
4289
+ return out;
4290
+ }
4238
4291
 
4239
4292
  // src/digest.ts
4240
4293
  function displayKey2(t) {
@@ -4328,9 +4381,21 @@ ${sorted(ts).map(blockedRow).join("\n")}
4328
4381
  const step1 = i.tickets.filter(
4329
4382
  (t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
4330
4383
  );
4384
+ const stopOf = (t) => i.repoStop ? i.repoStop(t) : null;
4331
4385
  const step2 = i.tickets.filter(
4332
- (t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
4386
+ (t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) === null
4333
4387
  );
4388
+ const held = i.tickets.filter((t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) !== null);
4389
+ const heldTable = held.length === 0 ? "" : `
4390
+ ### Held repos \u2014 this ship cannot start these right now
4391
+
4392
+ Not yours to start this run: the ship's checkout of their repo is stopped. It
4393
+ resumes by itself once the checkout is level; no one needs to change the ticket.
4394
+
4395
+ | ticket | why |
4396
+ |---|---|
4397
+ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
4398
+ `;
4334
4399
  return stream([
4335
4400
  "## Current queue \u2014 built for you by the poll\n",
4336
4401
  "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
@@ -4344,7 +4409,8 @@ ${sorted(ts).map(blockedRow).join("\n")}
4344
4409
  table(step2, i, header, row),
4345
4410
  "\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
4346
4411
  '\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
4347
- blockedTable(i.tickets.filter(isBlocked))
4412
+ blockedTable(i.tickets.filter(isBlocked)),
4413
+ ...heldTable ? [heldTable] : []
4348
4414
  ]);
4349
4415
  }
4350
4416
  function qaDigest(i) {
@@ -4394,6 +4460,9 @@ function branchTemplates(cfg, t) {
4394
4460
  const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
4395
4461
  return { name, push };
4396
4462
  }
4463
+ function pushedBranchForTicket(cfg, t, role) {
4464
+ return branchRenderer(t, role)(branchTemplates(cfg, t).push);
4465
+ }
4397
4466
  function existingBranchForTicket(dir, cfg, t, role) {
4398
4467
  return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
4399
4468
  }
@@ -4419,6 +4488,102 @@ function materializeBranchForTicket(dir, cfg, t, role) {
4419
4488
  // src/poll.ts
4420
4489
  import { writeFileSync as writeFileSync3 } from "fs";
4421
4490
  import { join as join6 } from "path";
4491
+
4492
+ // src/stalled.ts
4493
+ var STALLED_AFTER_CYCLES = 30;
4494
+ var CYCLE_MS = 120 * 1e3;
4495
+ var STALLED_AFTER_MS = STALLED_AFTER_CYCLES * CYCLE_MS;
4496
+ var STALLED_MARKER = "<!-- crew:stalled -->";
4497
+ var stalledKey = (issueId) => `stalled:${issueId}`;
4498
+ var ms = (iso) => {
4499
+ const n = iso ? Date.parse(iso) : NaN;
4500
+ return Number.isFinite(n) ? n : 0;
4501
+ };
4502
+ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now()) {
4503
+ const watched = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.handoff, contract.statuses.verifying]);
4504
+ const byTicket = /* @__PURE__ */ new Map();
4505
+ for (const c of comments) {
4506
+ const list = byTicket.get(c.ticket_id);
4507
+ if (list) list.push(c);
4508
+ else byTicket.set(c.ticket_id, [c]);
4509
+ }
4510
+ const out = [];
4511
+ for (const t of tickets) {
4512
+ if (!watched.has(t.status)) continue;
4513
+ if (t.assignee_id && holds2.has(t.assignee_id)) continue;
4514
+ const mine = byTicket.get(t.id) ?? [];
4515
+ const lastMarker = Math.max(0, ...mine.filter((c) => (c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at)));
4516
+ const lastActivityAt = Math.max(
4517
+ ms(t.updated_at),
4518
+ ...mine.filter((c) => !(c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at))
4519
+ );
4520
+ if (now - lastActivityAt < STALLED_AFTER_MS) continue;
4521
+ if (ships.some((s) => s.engaged && s.engaged_ticket_id === t.id)) continue;
4522
+ out.push({
4523
+ ticket: t,
4524
+ holderShipId: ticketHolderShipId(t, contract),
4525
+ lastActivityAt,
4526
+ announced: lastMarker > lastActivityAt
4527
+ });
4528
+ }
4529
+ return out;
4530
+ }
4531
+ function stalledComment(s, ships, now = Date.now()) {
4532
+ const mins = Math.round((now - s.lastActivityAt) / 6e4);
4533
+ const who2 = s.holderShipId ? `Held by ${shipName(ships, s.holderShipId)}, which is not engaged on it. That ship has an attention item open; the ticket is otherwise left as it is, and resumes the moment the ship picks it back up.` : `No ship holds it and none is engaged on it. It is waiting for the next ship that polls its lane; its status and assignee are left as they are.`;
4534
+ return `${STALLED_MARKER}
4535
+ **Stalled \u2014 no activity on \`${s.ticket.issue_id}\` for ${mins} minutes (\`${s.ticket.status}\`).**
4536
+
4537
+ ${who2}`;
4538
+ }
4539
+ async function applyStalled(writer, stalled, ships, memberId, myShipId, log, dryRun, now = Date.now()) {
4540
+ const r = { announced: 0, failed: 0, attention: [] };
4541
+ for (const s of stalled) {
4542
+ const x = { ticket: s.ticket.issue_id, step: "sweep" };
4543
+ if (s.holderShipId && s.holderShipId === myShipId) {
4544
+ r.attention.push({
4545
+ kind: "stalled",
4546
+ key: stalledKey(s.ticket.issue_id),
4547
+ message: `${s.ticket.issue_id} is held by this ship but has been quiet for ${Math.round((now - s.lastActivityAt) / 6e4)} minutes with nothing engaged on it`,
4548
+ since: new Date(s.lastActivityAt + STALLED_AFTER_MS).toISOString()
4549
+ });
4550
+ }
4551
+ if (s.announced) continue;
4552
+ if (dryRun) {
4553
+ log.emit("would flag as stalled", x);
4554
+ continue;
4555
+ }
4556
+ try {
4557
+ await writer.postEvent(s.ticket.id, stalledComment(s, ships, now), memberId);
4558
+ r.announced++;
4559
+ log.warn(`stalled at ${s.ticket.status} \u2014 ${s.holderShipId ? "flagged on the holder" : "commented, no holder"}`, x);
4560
+ } catch (e) {
4561
+ r.failed++;
4562
+ log.warn(`could not flag as stalled: ${e.message}`, x);
4563
+ }
4564
+ }
4565
+ return r;
4566
+ }
4567
+ async function runStalledSweep(o, stalled) {
4568
+ const r = await applyStalled(
4569
+ o.tracker,
4570
+ stalled,
4571
+ o.ships,
4572
+ o.memberId,
4573
+ o.myShipId,
4574
+ o.emit,
4575
+ o.dryRun ?? false
4576
+ );
4577
+ if (o.dryRun) return r;
4578
+ const keep = new Set(r.attention.map((i) => i.key));
4579
+ for (const item of r.attention) await raiseShipAttention({ state: o.state, emit: o.emit, route: o.route, ship: o.ship }, item);
4580
+ for (const open of o.state.shipAttention().previous()) {
4581
+ if (open.kind === "stalled" && !keep.has(open.key)) clearShipAttention(o.state, open.key);
4582
+ }
4583
+ return r;
4584
+ }
4585
+
4586
+ // src/poll.ts
4422
4587
  async function decideCycle(o) {
4423
4588
  const { route, ship, state, emit } = o;
4424
4589
  const tracker = new Tracker(route, ship);
@@ -4467,6 +4632,7 @@ async function decideCycle(o) {
4467
4632
  }
4468
4633
  const sweep = planSweep(tickets, info, blocked);
4469
4634
  const holdReleases = planHoldReleases(tickets, tracker.contract);
4635
+ const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2);
4470
4636
  const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
4471
4637
  emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
4472
4638
  const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
@@ -4526,7 +4692,11 @@ async function decideCycle(o) {
4526
4692
  }
4527
4693
  emit.enter("select");
4528
4694
  const watermark = state.watermark();
4695
+ for (const repo of recheckBaseStops(state, route)) {
4696
+ emit.emit(`${repo}: primary checkout is level again \u2014 building lanes resume for that repo`, { step: "select" });
4697
+ }
4529
4698
  const selectionInput = {
4699
+ repoStop: repoStopFor(openShipAttention(state), route),
4530
4700
  tickets,
4531
4701
  comments,
4532
4702
  watermark,
@@ -4549,6 +4719,7 @@ async function decideCycle(o) {
4549
4719
  sweep,
4550
4720
  epicSync,
4551
4721
  holdReleases,
4722
+ stalled,
4552
4723
  stranded,
4553
4724
  selection,
4554
4725
  actionable,
@@ -4593,6 +4764,7 @@ function writeDigest(o, d, role, stateDir) {
4593
4764
  const render2 = (t) => branchRenderer(t, role);
4594
4765
  const input = {
4595
4766
  dirFor,
4767
+ repoStop: repoStopFor(openShipAttention(o.state), o.route),
4596
4768
  branchFor: (t) => {
4597
4769
  const cfg = repoFor(dirFor(t) ?? o.route.dir);
4598
4770
  const { prefix } = ticketBranchContext(t);
@@ -5617,10 +5789,10 @@ function spawnAgent(plan, emit) {
5617
5789
  }));
5618
5790
  } catch {
5619
5791
  }
5620
- const ms = Date.now() - started;
5792
+ const ms2 = Date.now() - started;
5621
5793
  emit.emit(`agent run finished (exit ${code ?? 1})`, {
5622
5794
  data: {
5623
- ms,
5795
+ ms: ms2,
5624
5796
  code: code ?? 1,
5625
5797
  ...result?.sessionId ? { sessionId: result.sessionId } : {},
5626
5798
  ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
@@ -5648,7 +5820,7 @@ function spawnAgent(plan, emit) {
5648
5820
  emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
5649
5821
  }
5650
5822
  }
5651
- resolve5({ code: code ?? 1, ms });
5823
+ resolve5({ code: code ?? 1, ms: ms2 });
5652
5824
  });
5653
5825
  child.stdin.on("error", (err) => {
5654
5826
  emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
@@ -5657,6 +5829,201 @@ function spawnAgent(plan, emit) {
5657
5829
  });
5658
5830
  }
5659
5831
 
5832
+ // src/run-worktree.ts
5833
+ import { existsSync as existsSync9 } from "fs";
5834
+ import { dirname as dirname6, join as join9 } from "path";
5835
+
5836
+ // src/ports.ts
5837
+ import { execFileSync as execFileSync2 } from "child_process";
5838
+ import { existsSync as existsSync8 } from "fs";
5839
+ import { join as join8 } from "path";
5840
+ var run = (cmd, args) => {
5841
+ try {
5842
+ return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
5843
+ } catch {
5844
+ return "";
5845
+ }
5846
+ };
5847
+ function listeners() {
5848
+ const out = [];
5849
+ const seen = /* @__PURE__ */ new Set();
5850
+ const add = (pid, port) => {
5851
+ const key = `${pid}:${port}`;
5852
+ if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
5853
+ seen.add(key);
5854
+ out.push({ pid, port });
5855
+ };
5856
+ const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
5857
+ if (lsof) {
5858
+ for (const line of lsof.split("\n").slice(1)) {
5859
+ const cols = line.split(/\s+/);
5860
+ const addr = cols[8];
5861
+ if (!addr) continue;
5862
+ add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
5863
+ }
5864
+ return out;
5865
+ }
5866
+ const ss = run("ss", ["-ltnpH"]);
5867
+ for (const line of ss.split("\n")) {
5868
+ const local = line.trim().split(/\s+/)[3];
5869
+ const pid = /pid=(\d+)/.exec(line)?.[1];
5870
+ if (!local || !pid) continue;
5871
+ add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
5872
+ }
5873
+ return out;
5874
+ }
5875
+ var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
5876
+ var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
5877
+ function ticketForPort(port, s = DEFAULT_PORTS) {
5878
+ if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
5879
+ if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
5880
+ return null;
5881
+ }
5882
+ function worktreeExists(parent, prefix, n) {
5883
+ for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
5884
+ if (existsSync8(join8(parent, name, ".git"))) return true;
5885
+ }
5886
+ return false;
5887
+ }
5888
+ var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
5889
+ function findOrphansIn(where, s = DEFAULT_PORTS) {
5890
+ const orphans = [];
5891
+ for (const l of listeners()) {
5892
+ const t = ticketForPort(l.port, s);
5893
+ if (!t) continue;
5894
+ if (worktreeExistsIn(where, t.n)) continue;
5895
+ orphans.push({ ...l, ...t });
5896
+ }
5897
+ return orphans;
5898
+ }
5899
+ async function killGently(pids, graceMs = 2e3) {
5900
+ for (const pid of pids) {
5901
+ try {
5902
+ process.kill(pid, "SIGTERM");
5903
+ } catch {
5904
+ }
5905
+ }
5906
+ if (pids.length === 0) return;
5907
+ await new Promise((r) => setTimeout(r, graceMs));
5908
+ for (const pid of pids) {
5909
+ try {
5910
+ process.kill(pid, "SIGKILL");
5911
+ } catch {
5912
+ }
5913
+ }
5914
+ }
5915
+ function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
5916
+ const pids = /* @__PURE__ */ new Set();
5917
+ for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
5918
+ const matched = run("pgrep", ["-f", `${path}/`]);
5919
+ for (const line of matched.split("\n")) {
5920
+ const pid = Number(line.trim());
5921
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
5922
+ }
5923
+ return [...pids];
5924
+ }
5925
+
5926
+ // src/run-worktree.ts
5927
+ var describeRunWorktreePlan = (p) => {
5928
+ switch (p.action) {
5929
+ case "cut-remote":
5930
+ return `cut ${p.path} from the remote branch ${p.branch}`;
5931
+ case "cut-fresh":
5932
+ return `cut ${p.path} fresh from ${p.base} (no branch ${p.branch} anywhere yet)`;
5933
+ case "reset":
5934
+ return `fast-forwarded ${p.path} to the remote tip of ${p.branch}`;
5935
+ case "reuse":
5936
+ return `reusing ${p.path} (${p.branch} is already level with the remote)`;
5937
+ case "recut":
5938
+ return `${p.branch} is gone from the remote and ${p.path} held no work of its own \u2014 re-cut it fresh from ${p.base}`;
5939
+ case "leave-dirty":
5940
+ return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
5941
+ case "leave-unpushed":
5942
+ return `left ${p.path} alone: ${p.branch} has commits that are not on the remote`;
5943
+ }
5944
+ };
5945
+ var planIsUsable = (p) => p.action !== "leave-dirty" && p.action !== "leave-unpushed";
5946
+ function newBranchName(cfg, t, role) {
5947
+ const { prefix } = ticketBranchContext(t);
5948
+ return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
5949
+ }
5950
+ function planRunWorktree(dir, cfg, t, role) {
5951
+ const { remote, base } = cfg.branch;
5952
+ const hasRemote = remoteConfigured(dir, remote);
5953
+ if (hasRemote) fetchRemote(dir, remote);
5954
+ const num = t.issue_id.replace(/^\D+/, "");
5955
+ const existing = worktreeForNumber(dir, num);
5956
+ const located = locateBranchForTicket(dir, cfg, t, role);
5957
+ const baseRef = hasRemote ? `${remote}/${base}` : base;
5958
+ if (existing && !existing.prunable && existsSync9(existing.path)) {
5959
+ const branch = existing.branch;
5960
+ if (status(existing.path).length > 0) return { action: "leave-dirty", path: existing.path, branch };
5961
+ if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
5962
+ if (!hasRemote) return { action: "reuse", path: existing.path, branch };
5963
+ if (remoteBranchExists(dir, remote, branch)) {
5964
+ const ahead = countCommits(existing.path, `${remote}/${branch}..HEAD`);
5965
+ const behind = countCommits(existing.path, `HEAD..${remote}/${branch}`);
5966
+ if (ahead > 0) return { action: "leave-unpushed", path: existing.path, branch };
5967
+ return behind > 0 ? { action: "reset", path: existing.path, branch } : { action: "reuse", path: existing.path, branch };
5968
+ }
5969
+ if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
5970
+ return { action: "recut", path: existing.path, branch, base: baseRef };
5971
+ }
5972
+ const path = join9(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
5973
+ if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
5974
+ return { action: "cut-remote", path, branch: located.branch };
5975
+ }
5976
+ if (located) {
5977
+ return { action: "reuse", path, branch: located.branch };
5978
+ }
5979
+ return { action: "cut-fresh", path, branch: newBranchName(cfg, t, role), base: baseRef };
5980
+ }
5981
+ function applyRunWorktree(dir, cfg, plan) {
5982
+ const { remote } = cfg.branch;
5983
+ switch (plan.action) {
5984
+ case "leave-dirty":
5985
+ case "leave-unpushed":
5986
+ return null;
5987
+ case "reuse":
5988
+ if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
5989
+ return plan.path;
5990
+ case "reset":
5991
+ git(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
5992
+ return plan.path;
5993
+ case "cut-remote": {
5994
+ const local = gitOk(dir, ["rev-parse", "-q", "--verify", `refs/heads/${plan.branch}`]) !== null;
5995
+ if (local) {
5996
+ git(dir, ["worktree", "add", plan.path, plan.branch]);
5997
+ gitOk(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
5998
+ } else {
5999
+ git(dir, ["worktree", "add", "--track", "-b", plan.branch, plan.path, `${remote}/${plan.branch}`]);
6000
+ }
6001
+ return plan.path;
6002
+ }
6003
+ case "recut":
6004
+ git(dir, ["worktree", "remove", "--force", plan.path]);
6005
+ gitOk(dir, ["branch", "-D", plan.branch]);
6006
+ git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
6007
+ return plan.path;
6008
+ case "cut-fresh":
6009
+ git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
6010
+ return plan.path;
6011
+ }
6012
+ }
6013
+ async function finishRunWorktree(dir, cfg, branch) {
6014
+ const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
6015
+ if (!wt || !existsSync9(wt.path)) return { kind: "none" };
6016
+ if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
6017
+ if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
6018
+ const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
6019
+ if (Number.isFinite(n)) {
6020
+ const pids = pidsInWorktree(wt.path, n);
6021
+ if (pids.length) await killGently(pids);
6022
+ }
6023
+ gitOk(dir, ["worktree", "remove", wt.path]);
6024
+ return existsSync9(wt.path) ? { kind: "kept-dirty", path: wt.path } : { kind: "removed", path: wt.path };
6025
+ }
6026
+
5660
6027
  // src/handoff-guard.ts
5661
6028
  var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
5662
6029
  var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
@@ -5761,7 +6128,7 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
5761
6128
  branch: { ...route.branch, ...o?.branch }
5762
6129
  }, target.dir);
5763
6130
  const affinity = await tracker.claimAffinity();
5764
- return await enforceHandoff({
6131
+ const outcome = await enforceHandoff({
5765
6132
  writer: tracker,
5766
6133
  contract: tracker.contract,
5767
6134
  ticketId: workingId,
@@ -5773,20 +6140,37 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
5773
6140
  hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
5774
6141
  log
5775
6142
  });
6143
+ if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
6144
+ return outcome;
5776
6145
  } catch (e) {
5777
6146
  log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
5778
6147
  return null;
5779
6148
  }
5780
6149
  }
6150
+ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
6151
+ try {
6152
+ const branch = existingBranchForTicket(dir, cfg, t, role);
6153
+ if (!branch) return;
6154
+ const o = await finishRunWorktree(dir, cfg, branch);
6155
+ const at = { ticket: t.issue_id, step: "handoff" };
6156
+ 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);
6157
+ else if (o.kind === "kept-handoff") {
6158
+ log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
6159
+ 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);
6160
+ } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
6161
+ } catch (e) {
6162
+ log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
6163
+ }
6164
+ }
5781
6165
 
5782
6166
  // src/connect-wizard.ts
5783
- import { delimiter, dirname as dirname6, join as join8 } from "path";
5784
- import { accessSync as accessSync2, constants as constants2, existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
6167
+ import { delimiter, dirname as dirname7, join as join10 } from "path";
6168
+ import { accessSync as accessSync2, constants as constants2, existsSync as existsSync10, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
5785
6169
  import { stringify } from "yaml";
5786
6170
  var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
5787
6171
  function shouldRunWizard(opts) {
5788
6172
  if (!opts.isTTY) return false;
5789
- const exists = opts.configExists ?? existsSync8;
6173
+ const exists = opts.configExists ?? existsSync10;
5790
6174
  return !configSearchPath(opts.crewHome).some((p) => exists(p));
5791
6175
  }
5792
6176
  function firstRunConfigPath() {
@@ -5803,7 +6187,7 @@ function defaultIsExecutable(p) {
5803
6187
  function findOnPath2(name, pathEnv, isExecutable = defaultIsExecutable) {
5804
6188
  for (const dir of pathEnv.split(delimiter)) {
5805
6189
  if (!dir) continue;
5806
- const candidate = join8(dir, name);
6190
+ const candidate = join10(dir, name);
5807
6191
  if (isExecutable(candidate)) return candidate;
5808
6192
  }
5809
6193
  return void 0;
@@ -5829,9 +6213,9 @@ async function runWizard(p, env) {
5829
6213
  if (entered && isExec(entered)) agentBin = entered;
5830
6214
  else p.say(entered ? `${entered} is not an executable file.` : "A path is required.");
5831
6215
  }
5832
- const nodeDir = dirname6(env.nodePath);
6216
+ const nodeDir = dirname7(env.nodePath);
5833
6217
  const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
5834
- const baseDirs = [nodeDir, pnpmPath ? dirname6(pnpmPath) : void 0];
6218
+ const baseDirs = [nodeDir, pnpmPath ? dirname7(pnpmPath) : void 0];
5835
6219
  p.say(
5836
6220
  `
5837
6221
  Host Passengers lets this machine run the agent sessions that other people's
@@ -5842,7 +6226,7 @@ Tablation workspaces ask for, in a container. More: ${HOST_PASSENGERS_DOCS_URL}`
5842
6226
  p.say("Docker is not available here, so nothing will be hosted until it is. Your answer is recorded anyway.");
5843
6227
  }
5844
6228
  const dockerPath = hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
5845
- const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname6(dockerPath) : void 0]);
6229
+ const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname7(dockerPath) : void 0]);
5846
6230
  if (extraPath) {
5847
6231
  p.say(`Hooks will get ${extraPath} on their PATH (where node, pnpm${dockerPath ? " and docker" : ""} live).`);
5848
6232
  }
@@ -5864,7 +6248,7 @@ routes:
5864
6248
  ${routeBlock}`;
5865
6249
  }
5866
6250
  function writeNewConfig(path, text) {
5867
- mkdirSync6(dirname6(path), { recursive: true });
6251
+ mkdirSync6(dirname7(path), { recursive: true });
5868
6252
  try {
5869
6253
  writeFileSync5(path, text, { mode: 384, flag: "wx" });
5870
6254
  return true;
@@ -5898,8 +6282,8 @@ Wrote ${o.configPath}.
5898
6282
  }
5899
6283
 
5900
6284
  // src/release-run.ts
5901
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
5902
- import { dirname as dirname7, join as join9 } from "path";
6285
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
6286
+ import { dirname as dirname8, join as join11 } from "path";
5903
6287
  import { tmpdir as tmpdir2 } from "os";
5904
6288
 
5905
6289
  // src/release.ts
@@ -6159,6 +6543,139 @@ async function applyExternalClosures(tracker, closures, contract, emit, dryRun,
6159
6543
  return stamped;
6160
6544
  }
6161
6545
 
6546
+ // src/review.ts
6547
+ function reviewApplies(repo, contract) {
6548
+ return repo.release.mode === "external" && !!contract.statuses.reviewing;
6549
+ }
6550
+ function reviewProblems(repo, contract) {
6551
+ if (!reviewApplies(repo, contract)) return [];
6552
+ const problems = [];
6553
+ const template = repo.branch.push ?? repo.branch.name;
6554
+ if (!/\{(key|number|tag)\}/.test(template)) {
6555
+ problems.push(
6556
+ `branch.push "${template}" names none of {key}, {number} or {tag} \u2014 with release.mode "external" and a reviewing status, the pushed branch is the only link back to its ticket`
6557
+ );
6558
+ }
6559
+ return problems;
6560
+ }
6561
+ function parsePrRef(output) {
6562
+ const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
6563
+ if (lines.length === 1) return lines[0].slice(0, 500);
6564
+ for (const l of [...lines].reverse()) {
6565
+ if (/^https?:\/\/\S+$/.test(l) || /^#?\d+$/.test(l)) return l;
6566
+ }
6567
+ return void 0;
6568
+ }
6569
+ function columnValue(t, column) {
6570
+ const v = t[column];
6571
+ return typeof v === "string" ? v.trim() : "";
6572
+ }
6573
+ function movedPast(r, t, branch, pushedAs) {
6574
+ if (!r.columns.verifiedSha) return null;
6575
+ const verified = columnValue(t, r.contract.columns.verifiedSha);
6576
+ if (!verified) return null;
6577
+ const head = resolve2(r.cwd, branch);
6578
+ const remoteHead = resolve2(r.cwd, `${r.repo.branch.remote}/${pushedAs}`);
6579
+ if (remoteHead && !isAncestor(r.cwd, remoteHead, verified) && remoteHead !== head) {
6580
+ return { ticket: t, verified, head: remoteHead };
6581
+ }
6582
+ if (head && head !== verified && !isAncestor(r.cwd, head, verified)) {
6583
+ return { ticket: t, verified, head };
6584
+ }
6585
+ return null;
6586
+ }
6587
+ async function runReview(r, verified, reviewing) {
6588
+ const out = { handoffs: [], requeues: [] };
6589
+ const remote = r.repo.branch.remote;
6590
+ for (const { ticket, branch } of reviewing) {
6591
+ const moved = movedPast(r, ticket, branch, pushedBranchForTicket(r.repo, ticket));
6592
+ if (moved) out.requeues.push(moved);
6593
+ }
6594
+ for (const { ticket, branch } of verified) {
6595
+ const pushedAs = pushedBranchForTicket(r.repo, ticket);
6596
+ const moved = movedPast(r, ticket, branch, pushedAs);
6597
+ if (moved) {
6598
+ out.requeues.push(moved);
6599
+ continue;
6600
+ }
6601
+ const head = resolve2(r.cwd, branch);
6602
+ if (!head) continue;
6603
+ if (r.dryRun) {
6604
+ r.emit.emit(`would push ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
6605
+ continue;
6606
+ }
6607
+ try {
6608
+ pushBranch(r.cwd, remote, branch, pushedAs);
6609
+ } catch (e) {
6610
+ r.emit.error(
6611
+ `could not push ${branch} to ${remote}/${pushedAs} for review \u2014 ${e.message.split("\n")[0]}; left at verified, retried next cycle`,
6612
+ { ticket: ticket.issue_id }
6613
+ );
6614
+ continue;
6615
+ }
6616
+ r.emit.emit(`pushed ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
6617
+ const handoff = { ticket, pushedAs, head };
6618
+ const existing = r.columns.prRef ? columnValue(ticket, r.contract.columns.prRef) : "";
6619
+ if (existing) {
6620
+ handoff.prRef = existing;
6621
+ } else {
6622
+ const res = await r.runPrHook({
6623
+ CREW_TICKET: ticket.issue_id,
6624
+ CREW_BRANCH: pushedAs,
6625
+ CREW_BASE: r.repo.branch.base,
6626
+ CREW_TITLE: `${displayKey(ticket)}: ${ticket.title ?? ""}`.trim()
6627
+ });
6628
+ if (res && res.code !== 0) handoff.prFailure = { code: res.code, tail: lastLines(res.output, 5).trim() };
6629
+ else if (res) handoff.prRef = parsePrRef(res.output);
6630
+ }
6631
+ out.handoffs.push(handoff);
6632
+ }
6633
+ return out;
6634
+ }
6635
+ async function applyReview(tracker, contract, outcome, remote, emit, seat, columns, dryRun) {
6636
+ const reviewing = contract.statuses.reviewing;
6637
+ for (const h of outcome.handoffs) {
6638
+ if (!reviewing) break;
6639
+ const t = h.ticket;
6640
+ const lines = [`Pushed \`${h.pushedAs}\` (${h.head.slice(0, 8)}) to \`${remote}\` for review; moved to ${reviewing}.`];
6641
+ if (h.prRef) lines.push(`Pull request: ${h.prRef}`);
6642
+ if (h.prFailure) {
6643
+ lines.push(
6644
+ `\`hooks.pr\` exited ${h.prFailure.code}, so no pull request was recorded \u2014 the branch is pushed, open one by hand.
6645
+
6646
+ \`\`\`
6647
+ ${h.prFailure.tail}
6648
+ \`\`\``
6649
+ );
6650
+ }
6651
+ try {
6652
+ await tracker.updateTicket(t.id, {
6653
+ [contract.columns.status]: reviewing,
6654
+ ...h.prRef && columns.prRef ? { [contract.columns.prRef]: h.prRef } : {}
6655
+ }, t.updated_at);
6656
+ await tracker.postEvent(t.id, lines.join("\n\n"), seat);
6657
+ emit.emit(`moved to ${reviewing}`, { ticket: t.issue_id });
6658
+ } catch (e) {
6659
+ emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
6660
+ }
6661
+ }
6662
+ for (const q of outcome.requeues) {
6663
+ const t = q.ticket;
6664
+ const body = `The branch is no longer what was verified: \`verified_sha\` is ${q.verified.slice(0, 8)}, the branch is now at ${q.head.slice(0, 8)}. Returned to ${contract.statuses.handoff} for QA to check what changed.`;
6665
+ if (dryRun) {
6666
+ emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
6667
+ continue;
6668
+ }
6669
+ try {
6670
+ await tracker.updateTicket(t.id, { [contract.columns.status]: contract.statuses.handoff }, t.updated_at);
6671
+ await tracker.postEvent(t.id, body, seat);
6672
+ emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
6673
+ } catch (e) {
6674
+ emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
6675
+ }
6676
+ }
6677
+ }
6678
+
6162
6679
  // src/release-run.ts
6163
6680
  function emitReleaseSummary(emit, summaries) {
6164
6681
  if (summaries.length <= 1) return;
@@ -6258,11 +6775,15 @@ async function detectExternalClosures(o, candidates) {
6258
6775
  const out = [];
6259
6776
  for (const c of candidates) {
6260
6777
  if (!c.branch) continue;
6778
+ const prRef = c.ticket[o.contract.columns.prRef];
6261
6779
  const closure = await detectClosure({
6262
6780
  cwd: o.cwd,
6263
6781
  key: c.ticket.issue_id,
6264
6782
  aliases: referenceKeys(c.ticket).slice(1),
6265
- pushedBranch: c.branch,
6783
+ // The name the remote knows it by — `branch.push`, which is not the
6784
+ // local branch's when the repo sets one.
6785
+ pushedBranch: pushedBranchForTicket(o.repo, c.ticket),
6786
+ pr: typeof prRef === "string" && prRef.trim() ? prRef.trim() : void 0,
6266
6787
  remote: o.repo.branch.remote,
6267
6788
  base: o.repo.branch.base,
6268
6789
  mergedHook
@@ -6272,6 +6793,29 @@ async function detectExternalClosures(o, candidates) {
6272
6793
  }
6273
6794
  return out;
6274
6795
  }
6796
+ async function reviewHandoff(o, closures, verifiedCandidates, reviewing) {
6797
+ if (!reviewApplies(o.repo, o.contract)) return void 0;
6798
+ const problems = reviewProblems(o.repo, o.contract);
6799
+ if (problems.length) {
6800
+ for (const p of problems) o.emit.error(`${o.scope ?? "release"}: ${p}`);
6801
+ return void 0;
6802
+ }
6803
+ const open = new Set(closures.filter((c) => c.closure.state !== "merged").map((c) => c.ticket.id));
6804
+ const verified = verifiedCandidates.filter((c) => !!c.branch && open.has(c.ticket.id)).map((c) => ({ ticket: c.ticket, branch: c.branch }));
6805
+ const outcome = await runReview({
6806
+ cwd: o.cwd,
6807
+ repo: o.repo,
6808
+ contract: o.contract,
6809
+ emit: o.emit,
6810
+ dryRun: o.dryRun,
6811
+ columns: o.reviewColumns ?? { verifiedSha: false, prRef: false },
6812
+ runPrHook: async (env) => {
6813
+ const r = await hook(o, "pr", env);
6814
+ return r ? { code: r.code, output: r.output } : null;
6815
+ }
6816
+ }, verified, reviewing.filter((r) => open.has(r.ticket.id)));
6817
+ return outcome.handoffs.length || outcome.requeues.length ? outcome : void 0;
6818
+ }
6275
6819
  async function confirmExternalReleased(o, closures) {
6276
6820
  if (!o.repo.hooks.released) return closures;
6277
6821
  const landed = closures.filter((c) => c.closure.state === "merged" && c.closure.mergedAt);
@@ -6362,9 +6906,9 @@ async function bump(o, size) {
6362
6906
  }
6363
6907
  return printed;
6364
6908
  }
6365
- const files = o.repo.release.versionFiles.map((f) => join9(o.cwd, f));
6909
+ const files = o.repo.release.versionFiles.map((f) => join11(o.cwd, f));
6366
6910
  const first = files[0];
6367
- if (!existsSync9(first)) {
6911
+ if (!existsSync11(first)) {
6368
6912
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
6369
6913
  return null;
6370
6914
  }
@@ -6394,13 +6938,13 @@ async function bump(o, size) {
6394
6938
  function writeChangelog(o, version, entries) {
6395
6939
  const file = o.repo.release.changelog;
6396
6940
  if (!file || entries.length === 0) return;
6397
- const path = join9(o.cwd, file);
6941
+ const path = join11(o.cwd, file);
6398
6942
  const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
6399
6943
  if (o.dryRun) {
6400
6944
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
6401
6945
  return;
6402
6946
  }
6403
- const existing = existsSync9(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
6947
+ const existing = existsSync11(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
6404
6948
  writeFileSync6(path, insertChangelogSection(existing, section));
6405
6949
  }
6406
6950
  async function confirm(o, expected) {
@@ -6482,8 +7026,8 @@ async function confirmLastRelease(o, decision) {
6482
7026
  function openReleaseCheckout(o) {
6483
7027
  const { remote, base } = o.repo.branch;
6484
7028
  const branch = `crew/release-${Date.now()}`;
6485
- const root = mkdtempSync(join9(tmpdir2(), "crew-release-"));
6486
- const dir = join9(root, "checkout");
7029
+ const root = mkdtempSync(join11(tmpdir2(), "crew-release-"));
7030
+ const dir = join11(root, "checkout");
6487
7031
  const close = () => {
6488
7032
  gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
6489
7033
  rmSync2(root, { recursive: true, force: true });
@@ -6497,10 +7041,10 @@ function openReleaseCheckout(o) {
6497
7041
  throw e;
6498
7042
  }
6499
7043
  for (const f of o.repo.worktrees.copy) {
6500
- const src = join9(o.cwd, f);
6501
- if (!existsSync9(src)) continue;
6502
- mkdirSync7(dirname7(join9(dir, f)), { recursive: true });
6503
- copyFileSync(src, join9(dir, f));
7044
+ const src = join11(o.cwd, f);
7045
+ if (!existsSync11(src)) continue;
7046
+ mkdirSync7(dirname8(join11(dir, f)), { recursive: true });
7047
+ copyFileSync(src, join11(dir, f));
6504
7048
  }
6505
7049
  return { dir, branch, close };
6506
7050
  }
@@ -6546,9 +7090,18 @@ async function runRelease(o) {
6546
7090
  }
6547
7091
  if (o.repo.release.mode === "external") {
6548
7092
  o.emit.emit("release.mode is external \u2014 the crew hands work off and does not release");
6549
- const landed = await detectExternalClosures(o, decision.merges);
7093
+ const reviewingStatus = o.contract.statuses.reviewing;
7094
+ const reviewing = [];
7095
+ if (reviewingStatus) {
7096
+ for (const t of o.tickets.filter((x) => x.status === reviewingStatus)) {
7097
+ const branch = locateBranchForTicket(o.cwd, o.repo, t)?.branch;
7098
+ if (branch) reviewing.push({ ticket: t, branch });
7099
+ }
7100
+ }
7101
+ const landed = await detectExternalClosures(o, [...decision.merges, ...reviewing]);
6550
7102
  const externalClosures = await confirmExternalReleased(o, landed);
6551
- return { merged: [], deployed: false, stopped: "external", decision, externalClosures };
7103
+ const review = await reviewHandoff(o, externalClosures, decision.merges, reviewing);
7104
+ return { merged: [], deployed: false, stopped: "external", decision, externalClosures, review };
6552
7105
  }
6553
7106
  const { remote, base } = o.repo.branch;
6554
7107
  if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
@@ -6903,7 +7456,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
6903
7456
  }
6904
7457
 
6905
7458
  // src/watch.ts
6906
- import { watch as watchFile, existsSync as existsSync10, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
7459
+ import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
6907
7460
  var E = "\x1B[";
6908
7461
  var dim = (s) => `${E}2m${s}${E}0m`;
6909
7462
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -7004,7 +7557,7 @@ function renderHeaders(states, now = Date.now()) {
7004
7557
  return states.map((s) => renderHeader(s, now)).join("");
7005
7558
  }
7006
7559
  function readFrom(file, from) {
7007
- if (!existsSync10(file)) return { events: [], offset: from };
7560
+ if (!existsSync12(file)) return { events: [], offset: from };
7008
7561
  const size = statSync5(file).size;
7009
7562
  if (size < from) return readFrom(file, 0);
7010
7563
  if (size === from) return { events: [], offset: from };
@@ -7083,96 +7636,6 @@ function startWatch(o) {
7083
7636
  return stop;
7084
7637
  }
7085
7638
 
7086
- // src/ports.ts
7087
- import { execFileSync as execFileSync2 } from "child_process";
7088
- import { existsSync as existsSync11 } from "fs";
7089
- import { join as join10 } from "path";
7090
- var run = (cmd, args) => {
7091
- try {
7092
- return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
7093
- } catch {
7094
- return "";
7095
- }
7096
- };
7097
- function listeners() {
7098
- const out = [];
7099
- const seen = /* @__PURE__ */ new Set();
7100
- const add = (pid, port) => {
7101
- const key = `${pid}:${port}`;
7102
- if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
7103
- seen.add(key);
7104
- out.push({ pid, port });
7105
- };
7106
- const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
7107
- if (lsof) {
7108
- for (const line of lsof.split("\n").slice(1)) {
7109
- const cols = line.split(/\s+/);
7110
- const addr = cols[8];
7111
- if (!addr) continue;
7112
- add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
7113
- }
7114
- return out;
7115
- }
7116
- const ss = run("ss", ["-ltnpH"]);
7117
- for (const line of ss.split("\n")) {
7118
- const local = line.trim().split(/\s+/)[3];
7119
- const pid = /pid=(\d+)/.exec(line)?.[1];
7120
- if (!local || !pid) continue;
7121
- add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
7122
- }
7123
- return out;
7124
- }
7125
- var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
7126
- var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
7127
- function ticketForPort(port, s = DEFAULT_PORTS) {
7128
- if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
7129
- if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
7130
- return null;
7131
- }
7132
- function worktreeExists(parent, prefix, n) {
7133
- for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
7134
- if (existsSync11(join10(parent, name, ".git"))) return true;
7135
- }
7136
- return false;
7137
- }
7138
- var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
7139
- function findOrphansIn(where, s = DEFAULT_PORTS) {
7140
- const orphans = [];
7141
- for (const l of listeners()) {
7142
- const t = ticketForPort(l.port, s);
7143
- if (!t) continue;
7144
- if (worktreeExistsIn(where, t.n)) continue;
7145
- orphans.push({ ...l, ...t });
7146
- }
7147
- return orphans;
7148
- }
7149
- async function killGently(pids, graceMs = 2e3) {
7150
- for (const pid of pids) {
7151
- try {
7152
- process.kill(pid, "SIGTERM");
7153
- } catch {
7154
- }
7155
- }
7156
- if (pids.length === 0) return;
7157
- await new Promise((r) => setTimeout(r, graceMs));
7158
- for (const pid of pids) {
7159
- try {
7160
- process.kill(pid, "SIGKILL");
7161
- } catch {
7162
- }
7163
- }
7164
- }
7165
- function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
7166
- const pids = /* @__PURE__ */ new Set();
7167
- for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
7168
- const matched = run("pgrep", ["-f", `${path}/`]);
7169
- for (const line of matched.split("\n")) {
7170
- const pid = Number(line.trim());
7171
- if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
7172
- }
7173
- return [...pids];
7174
- }
7175
-
7176
7639
  // src/inbox.ts
7177
7640
  function matchMembers(rows, query) {
7178
7641
  const q = query.trim().toLowerCase();
@@ -7298,8 +7761,8 @@ async function decideFleet(o) {
7298
7761
  }
7299
7762
  var LOUD_STREAK = 5;
7300
7763
  function since(iso, now = Date.now()) {
7301
- const ms = Math.max(0, now - Date.parse(iso));
7302
- const mins = Math.round(ms / 6e4);
7764
+ const ms2 = Math.max(0, now - Date.parse(iso));
7765
+ const mins = Math.round(ms2 / 6e4);
7303
7766
  if (mins < 60) return `${mins}m`;
7304
7767
  const hours = Math.round(mins / 60);
7305
7768
  if (hours < 48) return `${hours}h`;
@@ -7334,8 +7797,8 @@ function nextRoles(decision, ran) {
7334
7797
  }
7335
7798
 
7336
7799
  // src/agents.ts
7337
- import { readFileSync as readFileSync8, existsSync as existsSync12 } from "fs";
7338
- import { join as join11 } from "path";
7800
+ import { readFileSync as readFileSync8, existsSync as existsSync13 } from "fs";
7801
+ import { join as join12 } from "path";
7339
7802
  import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
7340
7803
  var PERSONA_NAME = {
7341
7804
  dev: "Developer",
@@ -7349,11 +7812,11 @@ var CREW_AGENT_COLUMN = "agent_id";
7349
7812
  var AgentsSyncError = class extends Error {
7350
7813
  };
7351
7814
  function personaDefaultPrompt(promptsDir, role) {
7352
- const briefPath = join11(promptsDir, "personas", `lane-${role}.md`);
7353
- if (!existsSync12(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
7815
+ const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
7816
+ if (!existsSync13(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
7354
7817
  if (role === "pair") return readFileSync8(briefPath, "utf8");
7355
- const commonPath = join11(promptsDir, "personas", "common.md");
7356
- if (!existsSync12(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
7818
+ const commonPath = join12(promptsDir, "personas", "common.md");
7819
+ if (!existsSync13(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
7357
7820
  return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
7358
7821
  }
7359
7822
  async function resolveAgentId(route, role, opts) {
@@ -8020,8 +8483,8 @@ import {
8020
8483
  } from "@tablation/client";
8021
8484
 
8022
8485
  // src/skills.ts
8023
- import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync13 } from "fs";
8024
- import { join as join12 } from "path";
8486
+ import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
8487
+ import { join as join13 } from "path";
8025
8488
  import { parse as parseYaml } from "yaml";
8026
8489
  import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
8027
8490
  var SkillSyncError = class extends Error {
@@ -8040,9 +8503,9 @@ function parseSkillFile(path) {
8040
8503
  return { name: meta.name, description: meta.description, prompt: body, file: path };
8041
8504
  }
8042
8505
  function listLocalSkills(promptsDir) {
8043
- const dir = join12(promptsDir, "skills");
8044
- if (!existsSync13(dir)) return [];
8045
- return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join12(dir, f)));
8506
+ const dir = join13(promptsDir, "skills");
8507
+ if (!existsSync14(dir)) return [];
8508
+ return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
8046
8509
  }
8047
8510
  async function syncSkills(route, opts) {
8048
8511
  if (!route.resolved) {
@@ -8369,7 +8832,7 @@ function resolveRepoProject(projects, opt, routeAreaId) {
8369
8832
  import { spawnSync as spawnSync2 } from "child_process";
8370
8833
  import { readFileSync as readFileSync10 } from "fs";
8371
8834
  import { homedir as homedir4 } from "os";
8372
- import { join as join13 } from "path";
8835
+ import { join as join14 } from "path";
8373
8836
  var MCP_SERVER_NAME = "tablation";
8374
8837
  var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
8375
8838
  function mcpUrlFor(baseUrl) {
@@ -8382,7 +8845,7 @@ function runClaude(bin, args) {
8382
8845
  }
8383
8846
  function userScopeEntry(home) {
8384
8847
  try {
8385
- const j = JSON.parse(readFileSync10(join13(home, ".claude.json"), "utf8"));
8848
+ const j = JSON.parse(readFileSync10(join14(home, ".claude.json"), "utf8"));
8386
8849
  return j.mcpServers?.[MCP_SERVER_NAME];
8387
8850
  } catch {
8388
8851
  return void 0;
@@ -8469,7 +8932,7 @@ function schedulerInstalled(plan, exists, crontab) {
8469
8932
  }
8470
8933
 
8471
8934
  // src/worktree-sweep.ts
8472
- import { existsSync as existsSync14 } from "fs";
8935
+ import { existsSync as existsSync15 } from "fs";
8473
8936
  import { basename as basename6 } from "path";
8474
8937
  function planWorktreeSweep(target, tickets, contract) {
8475
8938
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -8493,7 +8956,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
8493
8956
  let removed = 0;
8494
8957
  let keptBranches = 0;
8495
8958
  for (const a of actions) {
8496
- if (!dryRun && existsSync14(a.path)) {
8959
+ if (!dryRun && existsSync15(a.path)) {
8497
8960
  let dirty = false;
8498
8961
  try {
8499
8962
  dirty = status(a.path).length > 0;
@@ -8581,14 +9044,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
8581
9044
  }
8582
9045
 
8583
9046
  // src/stream-sweep.ts
8584
- import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
8585
- import { join as join14 } from "path";
9047
+ import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
9048
+ import { join as join15 } from "path";
8586
9049
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
8587
9050
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
8588
- const dir = join14(stateDir, "streams");
8589
- if (!existsSync15(dir)) return [];
9051
+ const dir = join15(stateDir, "streams");
9052
+ if (!existsSync16(dir)) return [];
8590
9053
  const cutoff = now - retentionDays * DAY_MS2;
8591
- return readdirSync3(dir).map((name) => join14(dir, name)).filter((path) => {
9054
+ return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
8592
9055
  try {
8593
9056
  return statSync6(path).mtimeMs < cutoff;
8594
9057
  } catch {
@@ -8615,24 +9078,24 @@ function applyStreamSweep(paths, dryRun, log) {
8615
9078
  }
8616
9079
 
8617
9080
  // src/cli.ts
8618
- import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
9081
+ import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
8619
9082
  import { createInterface } from "readline/promises";
8620
9083
  import { dirname as dirOf, resolve as resolvePath } from "path";
8621
9084
 
8622
9085
  // src/ssh-keys.ts
8623
9086
  import { execFileSync as execFileSync3 } from "child_process";
8624
- import { existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
8625
- import { dirname as dirname9, join as join15 } from "path";
9087
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
9088
+ import { dirname as dirname10, join as join16 } from "path";
8626
9089
  var SshKeyError = class extends Error {
8627
9090
  };
8628
9091
  function shipSshKeyPathFor(stateDir) {
8629
- return join15(stateDir, "ssh", "id_ed25519");
9092
+ return join16(stateDir, "ssh", "id_ed25519");
8630
9093
  }
8631
9094
  function ensureShipSshKeypair(stateDir) {
8632
9095
  const privateKeyPath = shipSshKeyPathFor(stateDir);
8633
9096
  const publicKeyPath = `${privateKeyPath}.pub`;
8634
- if (!existsSync16(privateKeyPath)) {
8635
- mkdirSync8(dirname9(privateKeyPath), { recursive: true });
9097
+ if (!existsSync17(privateKeyPath)) {
9098
+ mkdirSync8(dirname10(privateKeyPath), { recursive: true });
8636
9099
  try {
8637
9100
  execFileSync3("ssh-keygen", [
8638
9101
  "-t",
@@ -8652,7 +9115,7 @@ function ensureShipSshKeypair(stateDir) {
8652
9115
  }
8653
9116
  chmodSync(privateKeyPath, 384);
8654
9117
  }
8655
- if (!existsSync16(publicKeyPath)) {
9118
+ if (!existsSync17(publicKeyPath)) {
8656
9119
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
8657
9120
  }
8658
9121
  const publicKey = readFileSync11(publicKeyPath, "utf8").trim();
@@ -8869,9 +9332,9 @@ function syncAllPassengerCheckouts(cfg) {
8869
9332
 
8870
9333
  // src/tunnel.ts
8871
9334
  import { spawn as spawn3 } from "child_process";
8872
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
9335
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8873
9336
  import { randomInt } from "crypto";
8874
- import { join as join16 } from "path";
9337
+ import { join as join17 } from "path";
8875
9338
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
8876
9339
  var defaultSpawn = (cmd, args) => spawn3(cmd, args, DEFAULT_SPAWN_OPTIONS);
8877
9340
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -8938,11 +9401,11 @@ function startTunnel(opts) {
8938
9401
  };
8939
9402
  }
8940
9403
  function tunnelStatePath(stateDir, workspaceId) {
8941
- return join16(stateDir, "passengers", `${workspaceId}.json`);
9404
+ return join17(stateDir, "passengers", `${workspaceId}.json`);
8942
9405
  }
8943
9406
  function readPersistedTunnel(stateDir, workspaceId) {
8944
9407
  const path = tunnelStatePath(stateDir, workspaceId);
8945
- if (!existsSync17(path)) return void 0;
9408
+ if (!existsSync18(path)) return void 0;
8946
9409
  try {
8947
9410
  return JSON.parse(readFileSync12(path, "utf8"));
8948
9411
  } catch {
@@ -8951,7 +9414,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
8951
9414
  }
8952
9415
  function writePersistedTunnel(stateDir, state) {
8953
9416
  const path = tunnelStatePath(stateDir, state.workspaceId);
8954
- mkdirSync9(join16(stateDir, "passengers"), { recursive: true });
9417
+ mkdirSync9(join17(stateDir, "passengers"), { recursive: true });
8955
9418
  writeFileSync7(path, `${JSON.stringify(state, null, 2)}
8956
9419
  `);
8957
9420
  }
@@ -9049,8 +9512,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
9049
9512
  }
9050
9513
  }
9051
9514
  function readAllPersistedWorkspaceIds(stateDir) {
9052
- const dir = join16(stateDir, "passengers");
9053
- if (!existsSync17(dir)) return [];
9515
+ const dir = join17(stateDir, "passengers");
9516
+ if (!existsSync18(dir)) return [];
9054
9517
  return readdirSync4(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
9055
9518
  }
9056
9519
 
@@ -9171,8 +9634,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
9171
9634
  import { createServer as createHttpServer } from "http";
9172
9635
  import { spawn as spawn4 } from "child_process";
9173
9636
  import { randomBytes } from "crypto";
9174
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
9175
- import { join as join17 } from "path";
9637
+ import { existsSync as existsSync19, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
9638
+ import { join as join18 } from "path";
9176
9639
 
9177
9640
  // src/secret-compare.ts
9178
9641
  import { timingSafeEqual } from "crypto";
@@ -9261,11 +9724,11 @@ var defaultSpawn2 = (cmd, args, env) => {
9261
9724
  return child;
9262
9725
  };
9263
9726
  function syncDaemonStatePath(stateDir, workspaceId) {
9264
- return join17(stateDir, "passengers", `${workspaceId}-sync.json`);
9727
+ return join18(stateDir, "passengers", `${workspaceId}-sync.json`);
9265
9728
  }
9266
9729
  function readPersistedSyncDaemon(stateDir, workspaceId) {
9267
9730
  const path = syncDaemonStatePath(stateDir, workspaceId);
9268
- if (!existsSync18(path)) return void 0;
9731
+ if (!existsSync19(path)) return void 0;
9269
9732
  try {
9270
9733
  return JSON.parse(readFileSync13(path, "utf8"));
9271
9734
  } catch {
@@ -9274,7 +9737,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
9274
9737
  }
9275
9738
  function writePersistedSyncDaemon(stateDir, state) {
9276
9739
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
9277
- mkdirSync10(join17(stateDir, "passengers"), { recursive: true });
9740
+ mkdirSync10(join18(stateDir, "passengers"), { recursive: true });
9278
9741
  writeFileSync8(path, `${JSON.stringify(state, null, 2)}
9279
9742
  `);
9280
9743
  }
@@ -9285,8 +9748,8 @@ function removePersistedSyncDaemon(stateDir, workspaceId) {
9285
9748
  } catch {
9286
9749
  }
9287
9750
  }
9288
- function sleepSync2(ms) {
9289
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
9751
+ function sleepSync2(ms2) {
9752
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
9290
9753
  }
9291
9754
  function waitForExit(pid, alive, timeoutMs = 5e3) {
9292
9755
  const deadline = Date.now() + timeoutMs;
@@ -9295,8 +9758,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
9295
9758
  }
9296
9759
  }
9297
9760
  function readAllPersistedSyncWorkspaceIds(stateDir) {
9298
- const dir = join17(stateDir, "passengers");
9299
- if (!existsSync18(dir)) return [];
9761
+ const dir = join18(stateDir, "passengers");
9762
+ if (!existsSync19(dir)) return [];
9300
9763
  const suffix = "-sync.json";
9301
9764
  return readdirSync5(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
9302
9765
  }
@@ -9354,10 +9817,10 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
9354
9817
 
9355
9818
  // src/daemon.ts
9356
9819
  import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
9357
- import { join as join18 } from "path";
9820
+ import { join as join19 } from "path";
9358
9821
  var DEFAULT_FLOOR_MS = 5e3;
9359
9822
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
9360
- var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
9823
+ var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
9361
9824
  async function runDaemonLoop(o) {
9362
9825
  const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
9363
9826
  const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
@@ -9393,7 +9856,7 @@ function latestMtime(path) {
9393
9856
  if (!stat.isDirectory()) return 0;
9394
9857
  let latest = stat.mtimeMs;
9395
9858
  for (const entry of readdirSync6(path, { withFileTypes: true })) {
9396
- latest = Math.max(latest, latestMtime(join18(path, entry.name)));
9859
+ latest = Math.max(latest, latestMtime(join19(path, entry.name)));
9397
9860
  }
9398
9861
  return latest;
9399
9862
  }
@@ -9460,6 +9923,24 @@ async function runOnePass(o) {
9460
9923
  const tracker = new Tracker(o.route, o.ship);
9461
9924
  await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
9462
9925
  }
9926
+ {
9927
+ const tracker = new Tracker(o.route, o.ship);
9928
+ const memberId = o.route.resolved?.seats.qa ?? o.route.resolved?.seats.dev;
9929
+ try {
9930
+ if (memberId) await runStalledSweep({
9931
+ tracker,
9932
+ state: o.state,
9933
+ emit,
9934
+ route: o.route,
9935
+ ship: o.ship,
9936
+ ships: decision.ships,
9937
+ memberId,
9938
+ myShipId: shipIdByName(decision.ships, o.ship.name)
9939
+ }, decision.stalled ?? []);
9940
+ } catch (e) {
9941
+ emit.warn(`could not sweep stalled tickets: ${e.message}`, { step: "sweep" });
9942
+ }
9943
+ }
9463
9944
  const order = [...decision.selection.pending].sort(
9464
9945
  (a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
9465
9946
  );
@@ -9659,7 +10140,7 @@ async function buildEnvironment(route, ship, ticket) {
9659
10140
  }
9660
10141
 
9661
10142
  // src/cli.ts
9662
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
10143
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname11(process.execPath) : resolve4(dirname11(fileURLToPath(import.meta.url)), "..");
9663
10144
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
9664
10145
  var CYCLE_SECONDS2 = 120;
9665
10146
  var STALE_CYCLES = 3;
@@ -9784,6 +10265,7 @@ function usage() {
9784
10265
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
9785
10266
  crew drop [route] NNN remove a merged ticket's worktree and branch
9786
10267
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
10268
+ crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
9787
10269
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
9788
10270
  crew sync [route] fast-forward the checkout and its worktrees from the remote
9789
10271
  crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
@@ -10148,8 +10630,8 @@ if (command === "passenger-sync-daemon") {
10148
10630
  }
10149
10631
  if (host === "linux" && hasSystemd) {
10150
10632
  const label = labelFor(CREW_HOME, "run");
10151
- const staleTimer = join19(homedir5(), ".config", "systemd", "user", `${label}.timer`);
10152
- if (existsSync19(staleTimer)) {
10633
+ const staleTimer = join20(homedir5(), ".config", "systemd", "user", `${label}.timer`);
10634
+ if (existsSync20(staleTimer)) {
10153
10635
  process.stdout.write(
10154
10636
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
10155
10637
  `
@@ -10175,7 +10657,7 @@ if (command === "passenger-sync-daemon") {
10175
10657
  const tracker = new Tracker(c, cfg.ship);
10176
10658
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
10177
10659
  for (const r of await resolvedRepos(c)) {
10178
- if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
10660
+ if (!existsSync20(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
10179
10661
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
10180
10662
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
10181
10663
  }
@@ -10193,7 +10675,7 @@ if (command === "passenger-sync-daemon") {
10193
10675
  }
10194
10676
  async function doctorFix(o) {
10195
10677
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
10196
- const unitExists = (p) => existsSync19(p);
10678
+ const unitExists = (p) => existsSync20(p);
10197
10679
  const crontab = o.host === "linux" ? spawnSync3("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
10198
10680
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
10199
10681
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -10336,6 +10818,10 @@ ${changed2.map((c) => ` - ${c}
10336
10818
  return { scope, tests: "skipped", outcome: "skipped", detail: `release ${why} on the board` };
10337
10819
  }
10338
10820
  boardLock = got;
10821
+ remit.emit(
10822
+ got.exclusion === "taken" ? `took board lock ${scope}` : `this board has no Locks table \u2014 cross-ship exclusion for ${scope} is OFF`,
10823
+ { step: "release" }
10824
+ );
10339
10825
  }
10340
10826
  const all = await tracker.openTickets();
10341
10827
  const { byRepo, unplaceable } = ticketsByRepo(c, all);
@@ -10349,6 +10835,7 @@ ${changed2.map((c) => ` - ${c}
10349
10835
  emit: remit,
10350
10836
  scope,
10351
10837
  state: state.release(scope),
10838
+ reviewColumns: repo.release.mode === "external" ? await tracker.reviewColumns() : void 0,
10352
10839
  dryRun,
10353
10840
  skipTests: flag("skip-tests"),
10354
10841
  shell: cfg.ship.shell,
@@ -10411,6 +10898,18 @@ ${changed2.map((c) => ` - ${c}
10411
10898
  if (outcome.externalClosures?.length) {
10412
10899
  await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
10413
10900
  }
10901
+ if (outcome.review) {
10902
+ await applyReview(
10903
+ tracker,
10904
+ tracker.contract,
10905
+ outcome.review,
10906
+ repo.branch.remote,
10907
+ remit,
10908
+ releaseSeat(c),
10909
+ await tracker.reviewColumns(),
10910
+ dryRun
10911
+ );
10912
+ }
10414
10913
  const news = describeRelease(outcome, scope);
10415
10914
  if (news) {
10416
10915
  const notified = await notify(c, cfg.ship, news, remit, dryRun);
@@ -11026,7 +11525,7 @@ ${changed2.map((c) => ` - ${c}
11026
11525
  process.exit(1);
11027
11526
  }
11028
11527
  requireArmed("run the daemon loop");
11029
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join19(CREW_HOME, "src"), join19(CREW_HOME, "dist"), join19(CREW_HOME, "bin")];
11528
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join20(CREW_HOME, "src"), join20(CREW_HOME, "dist"), join20(CREW_HOME, "bin")];
11030
11529
  const spawnBreaker = new SpawnBreaker();
11031
11530
  const stopReason = await runDaemonLoop({
11032
11531
  runPass: () => runOnePass({
@@ -11174,7 +11673,7 @@ ${changed2.map((c) => ` - ${c}
11174
11673
  `);
11175
11674
  if (!dryRun) {
11176
11675
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
11177
- const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
11676
+ const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
11178
11677
  raw.agentPersonas = personaResult.agentPersonas;
11179
11678
  raw.agentSkills = skillResult.agentSkills;
11180
11679
  mkdirSync11(dirOf(resolvedPath), { recursive: true });
@@ -11223,7 +11722,7 @@ ${changed2.map((c) => ` - ${c}
11223
11722
  `);
11224
11723
  if (!dryRun) {
11225
11724
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
11226
- const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
11725
+ const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
11227
11726
  raw.agentSkills = result.agentSkills;
11228
11727
  mkdirSync11(dirOf(resolvedPath), { recursive: true });
11229
11728
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -11246,7 +11745,7 @@ ${changed2.map((c) => ` - ${c}
11246
11745
  if (sub === "list" && !routeName) usage2();
11247
11746
  if (sub === "add") {
11248
11747
  try {
11249
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir5()))));
11748
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync20(resolvePath(p.replace(/^~(?=\/|$)/, homedir5()))));
11250
11749
  routeName = a.route;
11251
11750
  addPath = a.path;
11252
11751
  addName = a.name;
@@ -11261,7 +11760,7 @@ ${changed2.map((c) => ` - ${c}
11261
11760
  }
11262
11761
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
11263
11762
  let resolvedRemotes;
11264
- if (existsSync19(resolvedFile)) {
11763
+ if (existsSync20(resolvedFile)) {
11265
11764
  try {
11266
11765
  resolvedRemotes = JSON.parse(readFileSync14(resolvedFile, "utf8"));
11267
11766
  } catch {
@@ -11269,7 +11768,7 @@ ${changed2.map((c) => ` - ${c}
11269
11768
  }
11270
11769
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
11271
11770
  const text = configText;
11272
- const base = dirname10(cfg.configFile);
11771
+ const base = dirname11(cfg.configFile);
11273
11772
  try {
11274
11773
  if (sub === "list") {
11275
11774
  const repos = configuredRepos(text, routeName, base);
@@ -11278,14 +11777,14 @@ ${changed2.map((c) => ` - ${c}
11278
11777
  for (const r of repos) {
11279
11778
  const origin2 = originOf(r.dir);
11280
11779
  const row = matchRepoName(origin2, resolvedRemotes);
11281
- const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
11780
+ const note = !existsSync20(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
11282
11781
  process.stdout.write(`${r.name} ${r.dir} ${note}
11283
11782
  `);
11284
11783
  }
11285
11784
  break;
11286
11785
  }
11287
11786
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir5()));
11288
- const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
11787
+ const top = existsSync20(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
11289
11788
  if (!top) throw new ReposError(`${target} is not a git checkout`);
11290
11789
  const origin = originOf(top);
11291
11790
  const plan = planRepoAdd({
@@ -11333,7 +11832,7 @@ ${changed2.map((c) => ` - ${c}
11333
11832
  const created = await rowTracker.createRepoRow(rowPayload);
11334
11833
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
11335
11834
  `);
11336
- if (existsSync19(resolvedFile)) {
11835
+ if (existsSync20(resolvedFile)) {
11337
11836
  const raw = JSON.parse(readFileSync14(resolvedFile, "utf8"));
11338
11837
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
11339
11838
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -11578,7 +12077,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11578
12077
  operator = await pickOperator(found.holds);
11579
12078
  }
11580
12079
  let previous;
11581
- const hadPreviousFile = existsSync19(resolvedPath);
12080
+ const hadPreviousFile = existsSync20(resolvedPath);
11582
12081
  if (hadPreviousFile) {
11583
12082
  try {
11584
12083
  previous = JSON.parse(readFileSync14(resolvedPath, "utf8"));
@@ -11642,7 +12141,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11642
12141
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
11643
12142
  `);
11644
12143
  } else {
11645
- mkdirSync11(dirname10(resolvedPath), { recursive: true });
12144
+ mkdirSync11(dirname11(resolvedPath), { recursive: true });
11646
12145
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
11647
12146
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
11648
12147
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
@@ -11659,7 +12158,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11659
12158
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
11660
12159
  `);
11661
12160
  } else {
11662
- mkdirSync11(dirname10(apiKeyFile), { recursive: true });
12161
+ mkdirSync11(dirname11(apiKeyFile), { recursive: true });
11663
12162
  writeFileSync9(apiKeyFile, `${apiKeyVar}=${apiKey}
11664
12163
  `, { mode: 384 });
11665
12164
  process.stderr.write(`API key written to ${apiKeyFile}
@@ -11669,7 +12168,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11669
12168
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
11670
12169
  const wizard = firstRun;
11671
12170
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
11672
- const routeDir = value("dir") ?? (writeFirstRun ? join19(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
12171
+ const routeDir = value("dir") ?? (writeFirstRun ? join20(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
11673
12172
  const routeBlock = renderConnection(
11674
12173
  found,
11675
12174
  route2,
@@ -11984,6 +12483,40 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11984
12483
  emit.emit(`rotated the passenger tunnel for ${route.route} \u2014 it reconnects under a fresh slug and the registered endpoint follows on the next passengers cycle`);
11985
12484
  break;
11986
12485
  }
12486
+ case "worktree": {
12487
+ emit.enter("worktree");
12488
+ const rawWt = positional[2] ?? positional[1];
12489
+ const nWt = rawWt ? parseTicketNumber(rawWt) : null;
12490
+ if (!nWt) {
12491
+ process.stderr.write("worktree: need a ticket number (326, ISSUE-326, or TABL-326)\n");
12492
+ process.exit(2);
12493
+ }
12494
+ const trackerWt = new Tracker(route, cfg.ship);
12495
+ const cWt = trackerWt.contract;
12496
+ const ticketWt = (await trackerWt.openTickets()).find((t) => t[cWt.columns.key] === `ISSUE-${nWt}`);
12497
+ if (!ticketWt) {
12498
+ process.stderr.write(`worktree: no open ticket ISSUE-${nWt} on ${route.route}
12499
+ `);
12500
+ process.exit(2);
12501
+ }
12502
+ const repoWt = (await resolvedRepos(route)).find((r) => r.id === ticketWt.repo_id) ?? (await resolvedRepos(route))[0];
12503
+ if (!repoWt) {
12504
+ process.stderr.write("worktree: this route has no repository\n");
12505
+ process.exit(2);
12506
+ }
12507
+ const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
12508
+ const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt);
12509
+ if (dryRun) {
12510
+ emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
12511
+ break;
12512
+ }
12513
+ const pathWt = applyRunWorktree(repoWt.dir, repoWt.config, planWt);
12514
+ emit.emit(describeRunWorktreePlan(planWt));
12515
+ if (pathWt) process.stdout.write(`${pathWt}
12516
+ `);
12517
+ if (!planIsUsable(planWt)) process.exitCode = 1;
12518
+ break;
12519
+ }
11987
12520
  case "unassign": {
11988
12521
  emit.enter("select");
11989
12522
  const rawUnassign = positional[2] ?? positional[1];
@@ -12152,6 +12685,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12152
12685
  const engagedTicketIds = shipRows.map((s) => s.engaged_ticket_id).filter((id) => !!id);
12153
12686
  const engagedTickets = engagedTicketIds.length ? await statusTracker.ticketsByIds(engagedTicketIds) : [];
12154
12687
  const issueLabelById = new Map(engagedTickets.map((t) => [t.id, t.issue_id]));
12688
+ const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
12155
12689
  if (flag("json")) {
12156
12690
  const repos = await resolvedRepos(route);
12157
12691
  const seatIds = route.resolved?.seats ?? {};
@@ -12188,6 +12722,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12188
12722
  // derived `release_stale`. `version` stays 1.
12189
12723
  attention: openShipAttention(state)
12190
12724
  },
12725
+ // Additive (CREW-1402): what the operator owes. `version` stays 1.
12726
+ todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
12191
12727
  crew: {
12192
12728
  paused: state.isPaused(),
12193
12729
  // The release timer honors the whole-crew pause too, so this is
@@ -12234,6 +12770,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12234
12770
  `);
12235
12771
  break;
12236
12772
  }
12773
+ const owed = [
12774
+ ...todo.map((i) => `${displayKey(i.ticket)} \u2014 ${i.reasons.join(", ")}${i.ticket.title ? ` \u2014 ${i.ticket.title}` : ""}`),
12775
+ ...openShipAttention(state).map((i) => `ship ${cfg.ship.name}: ${i.kind} \u2014 ${i.message}`),
12776
+ ...shipRows.filter((s) => s.name !== cfg.ship.name).flatMap((s) => (s.attention ?? []).map((i) => `ship ${s.name}: ${i.kind} \u2014 ${i.message}`))
12777
+ ];
12778
+ if (owed.length) process.stdout.write(`to do (${owed.length}):
12779
+ ${owed.map((l) => ` ${l}
12780
+ `).join("")}
12781
+ `);
12237
12782
  process.stdout.write(
12238
12783
  `ship: ${cfg.ship.name} (${cfg.ship.platform})
12239
12784
  route: ${route.route} -> ${route.dir}