@tablation/crew 0.75.0 → 0.75.2

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.
Files changed (2) hide show
  1. package/dist/cli.js +1406 -1257
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5168,6 +5168,7 @@ var DEFAULT_CONTRACT = {
5168
5168
  resolutionNote: "resolution_note",
5169
5169
  verifiedSha: "verified_sha",
5170
5170
  prRef: "pr_ref",
5171
+ externalId: "external_id",
5171
5172
  position: "position"
5172
5173
  },
5173
5174
  comments: {
@@ -5652,6 +5653,29 @@ var State = class _State {
5652
5653
  }
5653
5654
  };
5654
5655
  }
5656
+ /**
5657
+ * The previous cycle's unroutable ticket ids for one route (CREW-2548): open
5658
+ * tickets with no repository that are not exempt. `poll.ts` diffs against it
5659
+ * so each ticket warns once when it becomes unroutable, not every cycle; the
5660
+ * caller persists the new set beside `attention(route).persist(...)`. A
5661
+ * missing or corrupt file reads as empty, so a fresh install warns once.
5662
+ */
5663
+ unroutable(route) {
5664
+ const file = `.unroutable-${_State.safe(route)}.json`;
5665
+ return {
5666
+ previous: () => {
5667
+ try {
5668
+ const parsed = JSON.parse(readFileSync12(this.path(file), "utf8"));
5669
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
5670
+ } catch {
5671
+ return [];
5672
+ }
5673
+ },
5674
+ persist: (next) => {
5675
+ writeFileSync8(this.path(file), JSON.stringify(next));
5676
+ }
5677
+ };
5678
+ }
5655
5679
  /**
5656
5680
  * Open ship-level attention items (CREW-1373), ship-wide rather than
5657
5681
  * per-route: a parked role or a missing hook command is about this
@@ -9349,6 +9373,14 @@ function filingErrors(tickets, opts = {}) {
9349
9373
  return !exempt.has(t[reportTypeColumn]);
9350
9374
  });
9351
9375
  }
9376
+ function unroutableChanges(unroutable, previous, tickets, externalIdColumn) {
9377
+ const prev = new Set(previous);
9378
+ const now = new Set(unroutable.map((t) => t.id));
9379
+ return {
9380
+ added: unroutable.filter((t) => !prev.has(t.id)).map((t) => ({ ticket: t, synced: !!t[externalIdColumn] })),
9381
+ gained: tickets.filter((t) => prev.has(t.id) && !now.has(t.id) && !!t.repo_id)
9382
+ };
9383
+ }
9352
9384
  function sweepComment(step) {
9353
9385
  return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
9354
9386
 
@@ -10131,15 +10163,30 @@ async function decideCycle(o) {
10131
10163
  emit.emit(`every child is done \u2014 this coordinating ticket is too`, { ticket: r.parent.issue_id });
10132
10164
  }
10133
10165
  }
10134
- for (const t of filingErrors(tickets, {
10166
+ const unroutableTickets = filingErrors(tickets, {
10135
10167
  ...coordinating,
10136
10168
  repoExemptValues: tracker.contract.repoExemptReportTypes
10137
- })) {
10169
+ });
10170
+ const unroutable = unroutableTickets.map((t) => t.id);
10171
+ const { added, gained } = unroutableChanges(
10172
+ unroutableTickets,
10173
+ state.unroutable(route.route).previous(),
10174
+ tickets,
10175
+ tracker.contract.columns.externalId
10176
+ );
10177
+ for (const { ticket: t, synced } of added) {
10138
10178
  emit.warn(
10139
- "no repo and not marked coordinating/question/investigation \u2014 a filing error, not an epic, and unroutable as written",
10179
+ synced ? "no repository \u2014 not crew work until one is set" : "no repo and not marked coordinating/question/investigation \u2014 a filing error, not an epic, and unroutable as written",
10140
10180
  { ticket: t.issue_id }
10141
10181
  );
10142
10182
  }
10183
+ for (const t of gained) emit.emit("now has a repository \u2014 no longer unroutable", { ticket: t.issue_id });
10184
+ if (unroutableTickets.length) {
10185
+ emit.emit(
10186
+ `${unroutableTickets.length} open ticket(s) have no repository and are not routable (${added.length} new since last cycle)`,
10187
+ { step: "sweep", data: { unroutable: unroutableTickets.map((t) => t.issue_id), newUnroutable: added.map((a) => a.ticket.issue_id) } }
10188
+ );
10189
+ }
10143
10190
  const attentionCurrent = {};
10144
10191
  for (const t of tickets) {
10145
10192
  const reasons = attentionReasons(t, tracker.contract);
@@ -10212,7 +10259,8 @@ async function decideCycle(o) {
10212
10259
  watermark,
10213
10260
  directionEvents,
10214
10261
  selectionInput,
10215
- attention: attentionCurrent
10262
+ attention: attentionCurrent,
10263
+ unroutable
10216
10264
  };
10217
10265
  const advanced = state.advanceWatermark([...comments, ...tickets]);
10218
10266
  if (advanced !== watermark) {
@@ -12505,1396 +12553,1447 @@ function formatWhoami(report) {
12505
12553
  `;
12506
12554
  }
12507
12555
 
12508
- // src/cli.ts
12509
- import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
12510
-
12511
- // src/release-ship.ts
12512
- var DAY_MS = 24 * 60 * 60 * 1e3;
12513
- function releaserFor(release, shipName2) {
12514
- const named = release.ship;
12515
- if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
12516
- return { releases: named.trim() === shipName2.trim(), releaser: named };
12517
- }
12518
- function nonReleaserNote(repo, releaser) {
12519
- return `release for ${repo} runs on ${releaser}`;
12520
- }
12521
- function missingReleaserWarning(repo, release, ships, now = Date.now()) {
12522
- if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
12523
- const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
12524
- if (live.length < 2) return null;
12525
- const names = live.map((s) => s.name ?? "?").join(", ");
12526
- return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
12527
- }
12528
-
12529
- // src/watch.ts
12530
- import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
12531
- var E = "\x1B[";
12532
- var dim = (s) => `${E}2m${s}${E}0m`;
12533
- var bold = (s) => `${E}1m${s}${E}0m`;
12534
- var paintWith = (n) => (s) => `${E}${n}m${s}${E}0m`;
12535
- var green = paintWith(32);
12536
- var yellow = paintWith(33);
12537
- var red = paintWith(31);
12538
- var cyan = paintWith(36);
12539
- var STEP_ORDER = ["poll", "sweep", "select", "worktree", "agent", "reconcile", "merge", "release", "idle"];
12540
- var WORKING_STEPS = /* @__PURE__ */ new Set(["worktree", "agent", "reconcile", "merge", "release"]);
12541
- function byRoute(events, route) {
12542
- return route ? events.filter((e) => e.route === route) : events;
12543
- }
12544
- function matches(e, f) {
12545
- if (f.role && e.role !== f.role) return false;
12546
- if (f.ticket && e.ticket !== f.ticket) return false;
12547
- if (f.route && e.route !== f.route) return false;
12548
- if (f.level === "warn" && e.level === "info") return false;
12549
- if (f.level === "error" && e.level !== "error") return false;
12550
- return true;
12551
- }
12552
- function localTime(iso) {
12553
- const d = new Date(iso);
12554
- if (Number.isNaN(d.getTime())) return iso.slice(11, 19);
12555
- const pad = (n) => String(n).padStart(2, "0");
12556
- return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
12557
- }
12558
- function formatLine(e, showRoute = false) {
12559
- const t = dim(localTime(e.at));
12560
- const scope = e.role ? `${e.step}[${e.role}]` : e.step;
12561
- const paint = e.level === "error" ? red : e.level === "warn" ? yellow : cyan;
12562
- const route = showRoute ? `${dim(e.route)} ` : "";
12563
- const ticket = e.ticket ? ` ${bold(e.ticket)}` : "";
12564
- return `${t} ${route}${paint(scope.padEnd(14))}${ticket} ${e.message}`;
12565
- }
12566
- function foldCycle(events) {
12567
- const last = events[events.length - 1];
12568
- if (!last) return null;
12569
- const ofCycle = events.filter((e) => e.cycle === last.cycle);
12570
- const first = ofCycle[0];
12571
- const state = {
12572
- cycle: last.cycle,
12573
- route: last.route,
12574
- step: last.step,
12575
- role: last.role,
12576
- ticket: [...ofCycle].reverse().find((e) => e.ticket && WORKING_STEPS.has(e.step))?.ticket,
12577
- startedAt: first.at,
12578
- lastAt: last.at,
12579
- warnings: ofCycle.filter((e) => e.level === "warn").length,
12580
- errors: ofCycle.filter((e) => e.level === "error").length
12581
- };
12582
- for (const e of ofCycle) {
12583
- if (typeof e.data?.rank === "number") state.rank = e.data.rank;
12584
- if (Array.isArray(e.data?.pending)) state.pending = e.data.pending;
12585
- if (Array.isArray(e.data?.blocked)) state.blockedTickets = e.data.blocked;
12586
- if (Array.isArray(e.data?.qaHeld)) state.qaHeldTickets = e.data.qaHeld;
12587
- if (e.step === "release" && typeof e.data?.cycles === "number") {
12588
- state.releaseBlockedCycles = e.data.cycles;
12589
- state.releaseBlockedReason = typeof e.data.reason === "string" ? e.data.reason : void 0;
12590
- }
12591
- }
12592
- return state;
12556
+ // src/seat-keys.ts
12557
+ import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl3 } from "@tablation/client";
12558
+ var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
12559
+ function keyIsAdmin(route) {
12560
+ const role = route.resolved?.role;
12561
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
12593
12562
  }
12594
- function foldCycles(events) {
12595
- const routes = [...new Set(events.map((e) => e.route))];
12596
- return routes.map((r) => foldCycle(events.filter((e) => e.route === r))).filter((s) => s !== null).sort((a, b) => Date.parse(b.lastAt) - Date.parse(a.lastAt));
12563
+ var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
12564
+ var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
12565
+ var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
12566
+ function seatKeySlug(workspaceSlug, agentId) {
12567
+ return `${workspaceSlug}#agent:${agentId}`;
12597
12568
  }
12598
- function renderHeader(s, now = Date.now()) {
12599
- if (!s) return dim("no cycle seen yet - waiting for the crew to run\n");
12600
- const elapsed = Math.max(0, Math.round((now - Date.parse(s.lastAt)) / 1e3));
12601
- const progress = STEP_ORDER.map((st) => st === s.step ? bold(green(st)) : dim(st)).join(dim(" > "));
12602
- const bits = [
12603
- `${bold(s.route)} cycle ${s.cycle}`,
12604
- s.role ? `role ${bold(s.role)}` : null,
12605
- s.ticket ? `ticket ${bold(s.ticket)}` : null,
12606
- s.rank !== void 0 ? dim(`rank ${s.rank}`) : null,
12607
- s.pending?.length ? dim(`pending: ${s.pending.join(" ")}`) : null,
12608
- s.errors ? red(`${s.errors} error(s)`) : s.warnings ? yellow(`${s.warnings} warning(s)`) : null,
12609
- dim(`${elapsed}s in ${s.step}`)
12610
- ].filter(Boolean);
12611
- const releaseLine = s.releaseBlockedCycles ? red(
12612
- `release BLOCKED (${s.releaseBlockedCycles} cycle${s.releaseBlockedCycles === 1 ? "" : "s"}): ${s.releaseBlockedReason ?? "unknown reason"}`
12613
- ) : null;
12614
- const extra = [
12615
- releaseLine,
12616
- s.blockedTickets?.length ? dim(`blocked: ${s.blockedTickets.join(" ")}`) : null,
12617
- s.qaHeldTickets?.length ? dim(`qa holding: ${s.qaHeldTickets.join(" ")}`) : null
12618
- ].filter(Boolean);
12619
- const extraLines = extra.length ? `${extra.join("\n")}
12620
- ` : "";
12621
- return `${bits.join(dim(" | "))}
12622
- ${extraLines}${progress}
12623
- ${dim("-".repeat(72))}
12624
- `;
12569
+ function slugFor(route, agentId) {
12570
+ const [workspaceSlug] = splitRoute(route.route);
12571
+ return seatKeySlug(workspaceSlug, agentId);
12625
12572
  }
12626
- function renderHeaders(states, now = Date.now()) {
12627
- if (!states.length) return dim("no cycle seen yet - waiting for the crew to run\n");
12628
- return states.map((s) => renderHeader(s, now)).join("");
12573
+ function seatAgentId(route, role) {
12574
+ return route.resolved?.agentPersonas?.[role]?.agentId;
12629
12575
  }
12630
- function readFrom(file, from) {
12631
- if (!existsSync23(file)) return { events: [], offset: from };
12632
- const size = statSync7(file).size;
12633
- if (size < from) return readFrom(file, 0);
12634
- if (size === from) return { events: [], offset: from };
12635
- const fd = openSync4(file, "r");
12636
- const buf = Buffer.alloc(size - from);
12637
- readSync2(fd, buf, 0, buf.length, from);
12638
- closeSync4(fd);
12639
- const text = buf.toString("utf8");
12640
- const lastNewline = text.lastIndexOf("\n");
12641
- if (lastNewline === -1) return { events: [], offset: from };
12642
- const events = [];
12643
- for (const line of text.slice(0, lastNewline).split("\n")) {
12644
- if (!line.trim()) continue;
12645
- try {
12646
- events.push(JSON.parse(line));
12647
- } catch {
12648
- }
12576
+ async function resolveReleaseKey(route, getStore = getSessionStore2) {
12577
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
12578
+ const agentId = route.resolved?.releaseAgentId;
12579
+ if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
12580
+ try {
12581
+ const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
12582
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12583
+ return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
12584
+ } catch {
12585
+ return ship("OS keychain unavailable");
12649
12586
  }
12650
- return { events, offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1), "utf8") };
12651
12587
  }
12652
- function startWatch(o) {
12653
- const out = o.out ?? ((s) => process.stdout.write(s));
12654
- const filter = o.filter ?? {};
12655
- let all = [];
12656
- let offset = 0;
12657
- let paused = false;
12658
- const showRoute = () => !filter.route && new Set(all.map((e) => e.route)).size > 1;
12659
- const seed = readFrom(o.file, 0);
12660
- all = seed.events;
12661
- offset = seed.offset;
12662
- for (const e of all.slice(-(o.tail ?? 20)).filter((x) => matches(x, filter))) out(`${formatLine(e, showRoute())}
12663
- `);
12664
- out(renderHeaders(foldCycles(byRoute(all, filter.route))));
12665
- const pump = () => {
12666
- const { events, offset: next } = readFrom(o.file, offset);
12667
- offset = next;
12668
- if (!events.length) return;
12669
- all = [...all, ...events].slice(-5e3);
12670
- if (paused) return;
12671
- for (const e of events) if (matches(e, filter)) out(`${formatLine(e, showRoute())}
12672
- `);
12673
- out(renderHeaders(foldCycles(byRoute(all, filter.route))));
12674
- };
12675
- const timer = setInterval(pump, 1e3);
12676
- let watcher;
12588
+ async function resolveSeatKey(route, role, getStore = getSessionStore2) {
12589
+ const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
12590
+ if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
12591
+ const agentId = seatAgentId(route, role);
12592
+ if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
12677
12593
  try {
12678
- watcher = watchFile(o.file, { persistent: false }, pump);
12594
+ const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
12595
+ if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
12596
+ return ship(keyIsAdmin(route) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
12679
12597
  } catch {
12598
+ return ship("OS keychain unavailable");
12680
12599
  }
12681
- const stdin = process.stdin;
12682
- const stop = () => {
12683
- clearInterval(timer);
12684
- watcher?.close();
12685
- if (stdin.isTTY) {
12686
- stdin.setRawMode(false);
12687
- stdin.pause();
12688
- stdin.off("data", onKey);
12689
- }
12690
- };
12691
- const onKey = (k) => {
12692
- const s = k.toString();
12693
- if (s === " ") {
12694
- paused = !paused;
12695
- out(dim(paused ? "\n-- paused (space to resume) --\n" : "-- resumed --\n"));
12696
- }
12697
- if (s === "q" || s === "") {
12698
- stop();
12699
- process.exit(0);
12700
- }
12701
- };
12702
- if (stdin.isTTY) {
12703
- stdin.setRawMode(true);
12704
- stdin.resume();
12705
- stdin.on("data", onKey);
12600
+ }
12601
+ function describeStoreError(e) {
12602
+ const msg = e instanceof Error ? e.message : String(e);
12603
+ if (/User interaction is not allowed/i.test(msg)) {
12604
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
12706
12605
  }
12707
- return stop;
12606
+ return "keychain unavailable (could not write to the OS keychain)";
12708
12607
  }
12709
-
12710
- // src/inbox.ts
12711
- function matchMembers(rows, query) {
12712
- const q = query.trim().toLowerCase();
12713
- const asEmail = q.includes("@");
12714
- return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q : (r.name ?? "").toLowerCase().includes(q)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
12715
- }
12716
- async function gatherInbox(routes, ship, opts = {}) {
12717
- const items = [];
12718
- const errors = [];
12719
- const resolved = [];
12720
- await Promise.all(routes.map(async (route) => {
12608
+ async function mintMissingSeatKeys(route, o) {
12609
+ const out = [];
12610
+ if (!route.resolved) return out;
12611
+ const resolved = route.resolved;
12612
+ const doFetch = o.fetchImpl ?? fetch;
12613
+ const targets = [
12614
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
12615
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
12616
+ ];
12617
+ let store;
12618
+ try {
12619
+ store = (o.getStore ?? getSessionStore2)();
12620
+ } catch (e) {
12621
+ return targets.map(({ role }) => ({
12622
+ role,
12623
+ action: "failed",
12624
+ detail: describeStoreError(e)
12625
+ }));
12626
+ }
12627
+ const host = hostFromUrl3(route.baseUrl);
12628
+ const base = route.baseUrl.replace(/\/+$/, "");
12629
+ if (!o.dryRun) {
12630
+ const probeSlug = seatKeySlug(splitRoute(route.route)[0], "_probe");
12721
12631
  try {
12722
- const tracker = new Tracker(route, ship);
12723
- const [tickets, crewRows] = await Promise.all([tracker.openTickets(), tracker.crewRows()]);
12724
- const roster = buildRoster(configuredMembers(route), crewRows);
12725
- let mine;
12726
- if (opts.memberQuery) {
12727
- const hits = matchMembers(crewRows, opts.memberQuery);
12728
- resolved.push({
12729
- route: route.route,
12730
- names: hits.map((h) => h.name),
12731
- emails: hits.map((h) => h.email).filter(Boolean),
12732
- by: hits[0]?.by ?? null
12733
- });
12734
- mine = new Set(hits.map((h) => h.id));
12735
- } else {
12736
- mine = opts.memberIds ?? new Set([route.resolved?.operator].filter(Boolean));
12632
+ await store.set(host, probeSlug, {
12633
+ apiKey: "probe",
12634
+ shipId: "",
12635
+ workspaceId: resolved.workspaceId,
12636
+ identityId: "_probe",
12637
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
12638
+ });
12639
+ await store.clear(host, probeSlug);
12640
+ } catch (e) {
12641
+ const detail = `${describeStoreError(e)}; no keys minted`;
12642
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
12643
+ }
12644
+ }
12645
+ for (const { role, agentId } of targets) {
12646
+ if (!agentId) {
12647
+ out.push({ role, action: "no_agent" });
12648
+ continue;
12649
+ }
12650
+ const slug = slugFor(route, agentId);
12651
+ try {
12652
+ if ((await store.get(host, slug))?.apiKey) {
12653
+ out.push({ role, action: "already_stored" });
12654
+ continue;
12737
12655
  }
12738
- const c = tracker.contract;
12739
- for (const t of tickets) {
12740
- if (!t.assignee_id || !mine.has(t.assignee_id)) continue;
12741
- if (opts.statuses && !opts.statuses.has(t.status)) continue;
12742
- if (!opts.includeClosed && c.statuses.resolved.includes(t.status)) continue;
12743
- items.push({
12744
- route: route.route,
12745
- ticket: t,
12746
- assignee: crewLabel(roster.get(t.assignee_id)),
12747
- rank: rankScalar(t, c),
12748
- effective: effectivePriority(t, c),
12749
- contract: c
12656
+ if (o.dryRun) {
12657
+ out.push({ role, action: "minted", detail: "dry run" });
12658
+ continue;
12659
+ }
12660
+ const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12661
+ method: "POST",
12662
+ headers: {
12663
+ Authorization: `Bearer ${resolveApiKey(route)}`,
12664
+ "Content-Type": "application/json",
12665
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
12666
+ },
12667
+ body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
12668
+ });
12669
+ if (res.status === 403) {
12670
+ out.push({ role, action: "forbidden" });
12671
+ break;
12672
+ }
12673
+ if (!res.ok) {
12674
+ out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
12675
+ continue;
12676
+ }
12677
+ const body = await res.json();
12678
+ if (!body.key) {
12679
+ out.push({ role, action: "failed", detail: "response carried no key" });
12680
+ continue;
12681
+ }
12682
+ try {
12683
+ const session = {
12684
+ apiKey: body.key,
12685
+ shipId: o.shipName ?? "",
12686
+ workspaceId: resolved.workspaceId,
12687
+ identityId: agentId,
12688
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
12689
+ ...body.id ? { keyId: body.id } : {}
12690
+ };
12691
+ await store.set(host, slug, session);
12692
+ } catch (e) {
12693
+ let revoked = false;
12694
+ if (body.id) {
12695
+ try {
12696
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
12697
+ method: "DELETE",
12698
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12699
+ });
12700
+ revoked = del2.ok;
12701
+ } catch {
12702
+ revoked = false;
12703
+ }
12704
+ }
12705
+ out.push({
12706
+ role,
12707
+ action: "failed",
12708
+ detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
12750
12709
  });
12710
+ continue;
12751
12711
  }
12712
+ out.push({ role, action: "minted" });
12752
12713
  } catch (e) {
12753
- errors.push({ route: route.route, error: e.message });
12714
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12754
12715
  }
12755
- }));
12756
- items.sort((a, b) => a.rank - b.rank);
12757
- const byName = resolved.filter((r) => r.by === "name" && r.names.length);
12758
- const distinct = new Set(byName.flatMap((r) => r.names));
12759
- const ambiguous = byName.length > 1 && distinct.size > 1;
12760
- return { items, errors, resolved, ambiguous };
12716
+ }
12717
+ return out;
12761
12718
  }
12762
- function renderInbox(items, byConnection = false, subject) {
12763
- if (items.length === 0) return `nothing assigned to ${subject ?? "you"}
12764
- `;
12765
- const key = (i) => byConnection ? i.route : i.ticket.status;
12766
- const groups = /* @__PURE__ */ new Map();
12767
- for (const i of items) {
12768
- const g = groups.get(key(i));
12769
- if (g) g.push(i);
12770
- else groups.set(key(i), [i]);
12719
+ function describeMintOutcome(o) {
12720
+ switch (o.action) {
12721
+ case "minted":
12722
+ return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
12723
+ case "already_stored":
12724
+ return `${o.role}: agent key already stored`;
12725
+ case "no_agent":
12726
+ return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
12727
+ case "forbidden":
12728
+ return FORBIDDEN_LINE;
12729
+ case "failed":
12730
+ return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
12771
12731
  }
12732
+ }
12733
+ async function revokeSeatKeys(route, o) {
12772
12734
  const out = [];
12773
- for (const [name, group] of groups) {
12774
- out.push(`${name} (${group.length})`);
12775
- for (const i of group) {
12776
- const t = i.ticket;
12777
- const where = byConnection ? t.status : i.route;
12778
- out.push(
12779
- ` ${displayKey(t).padEnd(10)} p${i.effective} ${(t.severity ?? "--").padEnd(3)} ${where.padEnd(14)} ${(t.title ?? "").slice(0, 62)}`
12780
- );
12781
- }
12782
- out.push("");
12735
+ if (!route.resolved) return out;
12736
+ const resolved = route.resolved;
12737
+ const doFetch = o.fetchImpl ?? fetch;
12738
+ const targets = [
12739
+ ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
12740
+ ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
12741
+ ];
12742
+ let store;
12743
+ try {
12744
+ store = (o.getStore ?? getSessionStore2)();
12745
+ } catch (e) {
12746
+ return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
12783
12747
  }
12784
- return out.join("\n");
12785
- }
12786
-
12787
- // src/fleet.ts
12788
- function agedConnectionRank(rawRank, role, route, decision, state, now) {
12789
- if (role !== "dev" && role !== "design") return rawRank;
12790
- const waiting = state.fairness(route.route).waiting();
12791
- if (!waiting || waiting.ticket !== decision.actionable.top?.issue_id) return rawRank;
12792
- return agedRankScalar(rawRank, { since: waiting.since, weight: route.weight }, now);
12793
- }
12794
- async function decideFleet(o) {
12795
- const now = o.now ?? Date.now();
12796
- const eligible = o.routes.filter((c) => o.enabledOnly === false ? true : c.enabled);
12797
- const entries = await Promise.all(eligible.map(async (route) => {
12748
+ const host = hostFromUrl3(route.baseUrl);
12749
+ const base = route.baseUrl.replace(/\/+$/, "");
12750
+ for (const { role, agentId } of targets) {
12751
+ if (!agentId) {
12752
+ out.push({ role, action: "no_agent" });
12753
+ continue;
12754
+ }
12755
+ const slug = slugFor(route, agentId);
12798
12756
  try {
12799
- const decision = await decideCycle({ route, ship: o.ship, state: o.state, emit: o.emit.forRoute(route.route) });
12800
- const sel = decision.selection;
12801
- if (!sel.selected) return { route, decision, rank: NOTHING_ACTIONABLE };
12802
- const rawRank = sel.ranks[sel.selected] ?? NOTHING_ACTIONABLE;
12803
- return {
12804
- route,
12805
- decision,
12806
- role: sel.selected,
12807
- rank: agedConnectionRank(rawRank, sel.selected, route, decision, o.state, now)
12808
- };
12757
+ const session = await store.get(host, slug);
12758
+ if (!session?.apiKey) {
12759
+ out.push({ role, action: "not_stored" });
12760
+ continue;
12761
+ }
12762
+ if (!session.keyId) {
12763
+ out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
12764
+ continue;
12765
+ }
12766
+ if (o.dryRun) {
12767
+ out.push({ role, action: "revoked", detail: "dry run" });
12768
+ continue;
12769
+ }
12770
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
12771
+ method: "DELETE",
12772
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12773
+ });
12774
+ if (!del2.ok && del2.status !== 404) {
12775
+ out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
12776
+ continue;
12777
+ }
12778
+ await store.clear(host, slug);
12779
+ out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
12809
12780
  } catch (e) {
12810
- return { route, rank: NOTHING_ACTIONABLE, error: e.message };
12781
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
12811
12782
  }
12812
- }));
12813
- for (const e of entries) {
12814
- if (e.error) o.emit.forRoute(e.route.route).error(`${e.route.route}: ${e.error}`, { step: "poll" });
12815
12783
  }
12816
- const contenders = entries.filter((e) => e.role && e.rank < NOTHING_ACTIONABLE);
12817
- const best = contenders.reduce(
12818
- (acc, e) => !acc || e.rank < acc.rank ? e : acc,
12819
- void 0
12820
- );
12821
- for (const e of entries) {
12822
- if (e.error || !e.decision) continue;
12823
- const won = best?.route.route === e.route.route;
12824
- o.state.fairness(e.route.route).record(e.decision.actionable.count, e.decision.actionable.top?.issue_id, won);
12825
- if (!o.dryRun) o.state.attention(e.route.route).persist(e.decision.attention);
12784
+ return out;
12785
+ }
12786
+ function describeRevokeOutcome(o) {
12787
+ switch (o.action) {
12788
+ case "revoked":
12789
+ return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
12790
+ case "not_stored":
12791
+ return `${o.role}: no agent key stored on this ship`;
12792
+ case "no_agent":
12793
+ return `${o.role}: no linked Agents row`;
12794
+ case "failed":
12795
+ return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
12826
12796
  }
12827
- return {
12828
- entries,
12829
- winner: best && best.role && best.decision ? { route: best.route, role: best.role, decision: best.decision } : void 0,
12830
- unreachable: entries.filter((e) => e.error).map((e) => e.route.route)
12831
- };
12832
12797
  }
12833
- var LOUD_STREAK = 5;
12834
- function since(iso, now = Date.now()) {
12835
- const ms2 = Math.max(0, now - Date.parse(iso));
12836
- const mins = Math.round(ms2 / 6e4);
12837
- if (mins < 60) return `${mins}m`;
12838
- const hours = Math.round(mins / 60);
12839
- if (hours < 48) return `${hours}h`;
12840
- return `${Math.round(hours / 24)}d`;
12798
+ async function canMintAgentKeys(route, o = {}) {
12799
+ const resolved = route.resolved;
12800
+ if (!resolved) return void 0;
12801
+ const agentId = seatAgentId(route, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route, r)).find(Boolean);
12802
+ if (!agentId) return void 0;
12803
+ try {
12804
+ const res = await (o.fetchImpl ?? fetch)(`${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
12805
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12806
+ });
12807
+ if (res.ok) return true;
12808
+ return res.status === 403 ? false : void 0;
12809
+ } catch {
12810
+ return void 0;
12811
+ }
12841
12812
  }
12842
- function renderFleet(f, state) {
12843
- const rows = f.entries.map((e) => {
12844
- if (e.error) return ` ${e.route.route.padEnd(16)} unreachable \u2014 ${e.error}`;
12845
- if (!e.role) return ` ${e.route.route.padEnd(16)} nothing pending`;
12846
- const pending = e.decision?.selection.pending.join(" ") ?? "";
12847
- const win = f.winner?.route.route === e.route.route ? " <- runs this cycle" : "";
12848
- const streak = state?.fairness(e.route.route).streak() ?? 0;
12849
- const passedOver = !win && streak >= LOUD_STREAK ? ` ! passed over ${streak} cycles running` : "";
12850
- return ` ${e.route.route.padEnd(16)} ${e.role.padEnd(7)} rank ${String(e.rank).padStart(12)} pending: ${pending}${win}${passedOver}`;
12851
- });
12852
- if (!rows.length) return "no enabled routes\n";
12853
- return `${rows.join("\n")}
12854
- `;
12813
+
12814
+ // src/seat-read-probe.ts
12815
+ var SEAT_NO_READ_REMEDY = "the project has no default role for agent keys \u2014 update the Issues app's template, then re-mint with `crew agents sync --revoke-seat-keys`, or assign the Crew seat role to the agent members";
12816
+ async function probeIssuesRead(route, apiKey, o = {}) {
12817
+ const resolved = route.resolved;
12818
+ if (!resolved?.models?.issues) return { state: "unknown", detail: "route has no resolved Issues table" };
12819
+ const url = `${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/data-models/${resolved.models.issues}/records?limit=1`;
12820
+ try {
12821
+ const res = await (o.fetchImpl ?? fetch)(url, {
12822
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
12823
+ });
12824
+ if (res.ok) return { state: "ok" };
12825
+ if (res.status === 403) return { state: "forbidden" };
12826
+ return { state: "unknown", detail: `HTTP ${res.status}` };
12827
+ } catch (e) {
12828
+ return { state: "unknown", detail: e.message };
12829
+ }
12855
12830
  }
12856
- function snapshot(tickets) {
12831
+ async function probeSeatReads(route, o = {}) {
12832
+ const out = [];
12833
+ const roles = [...SEAT_KEY_ROLES.filter((r) => route.resolved?.seats[r]), ...route.resolved?.releaseSeat ? ["release"] : []];
12834
+ for (const role of roles) {
12835
+ const id = role === "release" ? await resolveReleaseKey(route, o.getStore) : await resolveSeatKey(route, role, o.getStore);
12836
+ if (id.kind !== "agent") continue;
12837
+ out.push({ role, ...await probeIssuesRead(route, id.apiKey, o) });
12838
+ }
12839
+ return out;
12840
+ }
12841
+ function describeSeatForbidden(role) {
12842
+ return `seat read: ERROR ${role} agent key cannot read Issues (403) \u2014 ${SEAT_NO_READ_REMEDY}`;
12843
+ }
12844
+ function isForbiddenError(e) {
12845
+ const status2 = e?.status;
12846
+ if (status2 === 403) return true;
12847
+ return /\b403\b/.test(e instanceof Error ? e.message : String(e));
12848
+ }
12849
+ var SEAT_FORBIDDEN_KEY = "seat_forbidden:release";
12850
+ function seatForbiddenItem(now = /* @__PURE__ */ new Date()) {
12857
12851
  return {
12858
- state: new Map(tickets.map((t) => [t.id, `${t.status}|${t.assignee_id ?? ""}|${t.updated_at}`]))
12852
+ kind: "seat_forbidden",
12853
+ key: SEAT_FORBIDDEN_KEY,
12854
+ message: `release seat cannot read the Issues table (403) \u2014 ${SEAT_NO_READ_REMEDY}`,
12855
+ since: now.toISOString()
12859
12856
  };
12860
12857
  }
12861
- function changed(before, after) {
12862
- if (before.state.size !== after.state.size) return true;
12863
- for (const [id, v] of after.state) if (before.state.get(id) !== v) return true;
12864
- return false;
12865
- }
12866
- function nextRoles(decision, ran) {
12867
- return decision.selection.pending.filter((r) => !ran.includes(r)).sort((a, b) => (decision.selection.ranks[a] ?? 0) - (decision.selection.ranks[b] ?? 0));
12858
+ function seatForbiddenRepoLine(scope) {
12859
+ return `${scope}: skipped \u2014 release seat cannot read Issues (see attention)`;
12868
12860
  }
12869
12861
 
12870
- // src/ship-name.ts
12871
- import { existsSync as existsSync25, mkdirSync as mkdirSync16, readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
12872
- import { dirname as dirname14 } from "path";
12873
- import { parseDocument } from "yaml";
12862
+ // src/cli.ts
12863
+ import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
12874
12864
 
12875
- // src/machine-id.ts
12876
- import { execFileSync as execFileSync4 } from "child_process";
12877
- import { createHash as createHash4 } from "crypto";
12878
- import { existsSync as existsSync24, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs";
12879
- import { dirname as dirname13, join as join27 } from "path";
12880
- function machineIdPathFor(stateDir) {
12881
- return join27(stateDir, "machine-id");
12882
- }
12883
- var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
12884
- function readRawMachineId(platform2 = process.platform) {
12885
- try {
12886
- if (platform2 === "darwin") {
12887
- const out = execFileSync4("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
12888
- encoding: "utf8",
12889
- stdio: ["ignore", "pipe", "ignore"]
12890
- });
12891
- return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
12892
- }
12893
- for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
12894
- if (existsSync24(p)) {
12895
- const id = readFileSync21(p, "utf8").trim();
12896
- if (id) return id;
12897
- }
12898
- }
12899
- } catch {
12900
- }
12901
- return void 0;
12865
+ // src/release-ship.ts
12866
+ var DAY_MS = 24 * 60 * 60 * 1e3;
12867
+ function releaserFor(release, shipName2) {
12868
+ const named = release.ship;
12869
+ if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
12870
+ return { releases: named.trim() === shipName2.trim(), releaser: named };
12902
12871
  }
12903
- function ensureMachineId(stateDir, opts = {}) {
12904
- if (opts.override?.trim()) return sha256(opts.override.trim());
12905
- const path = machineIdPathFor(stateDir);
12906
- if (existsSync24(path)) {
12907
- const cached = readFileSync21(path, "utf8").trim();
12908
- if (/^[0-9a-f]{64}$/.test(cached)) return cached;
12909
- }
12910
- const raw = (opts.read ?? readRawMachineId)();
12911
- if (!raw) return void 0;
12912
- const hashed = sha256(raw);
12913
- mkdirSync15(dirname13(path), { recursive: true });
12914
- writeFileSync17(path, `${hashed}
12915
- `, { mode: 384 });
12916
- return hashed;
12872
+ function nonReleaserNote(repo, releaser) {
12873
+ return `release for ${repo} runs on ${releaser}`;
12917
12874
  }
12918
- function matchShipRows(ships, shipName2, machineId) {
12919
- const hasColumn = ships.some((r) => "machine_id" in r);
12920
- const byId = hasColumn && machineId ? ships.filter((r) => r.machine_id === machineId) : [];
12921
- if (byId.length > 0) return { rows: byId, by: "machine_id", hasColumn };
12922
- return { rows: ships.filter((r) => (r.name ?? "").trim() === shipName2.trim()), by: "name", hasColumn };
12875
+ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
12876
+ if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
12877
+ const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
12878
+ if (live.length < 2) return null;
12879
+ const names = live.map((s) => s.name ?? "?").join(", ");
12880
+ return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
12923
12881
  }
12924
12882
 
12925
- // src/ship-name.ts
12926
- var ShipNameError = class extends Error {
12927
- };
12928
- function validateShipName(raw) {
12929
- const name = raw.trim();
12930
- if (!name) throw new ShipNameError("the ship name cannot be empty");
12931
- if (/[\u0000-\u001f\u007f]/.test(name)) throw new ShipNameError("the ship name cannot contain control characters");
12932
- return name;
12933
- }
12934
- function planNameSync(local, row, lastSynced) {
12935
- if (local === row) return { drift: "none", action: "none" };
12936
- const rowMoved = lastSynced === void 0 || row !== lastSynced;
12937
- const localMoved = lastSynced === void 0 || local !== lastSynced;
12938
- if (rowMoved && localMoved) return { drift: "both", action: "row-wins" };
12939
- if (rowMoved) return { drift: "row", action: "adopt" };
12940
- return { drift: "local", action: "push" };
12883
+ // src/watch.ts
12884
+ import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
12885
+ var E = "\x1B[";
12886
+ var dim = (s) => `${E}2m${s}${E}0m`;
12887
+ var bold = (s) => `${E}1m${s}${E}0m`;
12888
+ var paintWith = (n) => (s) => `${E}${n}m${s}${E}0m`;
12889
+ var green = paintWith(32);
12890
+ var yellow = paintWith(33);
12891
+ var red = paintWith(31);
12892
+ var cyan = paintWith(36);
12893
+ var STEP_ORDER = ["poll", "sweep", "select", "worktree", "agent", "reconcile", "merge", "release", "idle"];
12894
+ var WORKING_STEPS = /* @__PURE__ */ new Set(["worktree", "agent", "reconcile", "merge", "release"]);
12895
+ function byRoute(events, route) {
12896
+ return route ? events.filter((e) => e.route === route) : events;
12941
12897
  }
12942
- function shipNameOfYaml(text) {
12943
- const v = parseDocument(text).getIn(["ship", "name"]);
12944
- return typeof v === "string" && v.trim() ? v.trim() : void 0;
12898
+ function matches(e, f) {
12899
+ if (f.role && e.role !== f.role) return false;
12900
+ if (f.ticket && e.ticket !== f.ticket) return false;
12901
+ if (f.route && e.route !== f.route) return false;
12902
+ if (f.level === "warn" && e.level === "info") return false;
12903
+ if (f.level === "error" && e.level !== "error") return false;
12904
+ return true;
12945
12905
  }
12946
- function setShipNameInYaml(text, name) {
12947
- const doc = parseDocument(text);
12948
- doc.setIn(["ship", "name"], name);
12949
- return doc.toString();
12906
+ function localTime(iso) {
12907
+ const d = new Date(iso);
12908
+ if (Number.isNaN(d.getTime())) return iso.slice(11, 19);
12909
+ const pad = (n) => String(n).padStart(2, "0");
12910
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
12950
12911
  }
12951
- function pickShipRow(rows, machineId, byName = []) {
12952
- const hasColumn = rows.some((r) => "machine_id" in r);
12953
- if (!hasColumn || !machineId) return { kind: "unsupported" };
12954
- const byId = rows.filter((r) => r.machine_id === machineId);
12955
- if (byId.length === 1) return { kind: "row", row: byId[0], by: "machine_id" };
12956
- if (byId.length > 1) return { kind: "ambiguous" };
12957
- const names = new Set(byName.map((n) => n.trim()));
12958
- const named = rows.filter((r) => !r.machine_id && names.has((r.name ?? "").trim()));
12959
- if (named.length === 1) return { kind: "row", row: named[0], by: "name" };
12960
- return { kind: named.length > 1 ? "ambiguous" : "none" };
12912
+ function formatLine(e, showRoute = false) {
12913
+ const t = dim(localTime(e.at));
12914
+ const scope = e.role ? `${e.step}[${e.role}]` : e.step;
12915
+ const paint = e.level === "error" ? red : e.level === "warn" ? yellow : cyan;
12916
+ const route = showRoute ? `${dim(e.route)} ` : "";
12917
+ const ticket = e.ticket ? ` ${bold(e.ticket)}` : "";
12918
+ return `${t} ${route}${paint(scope.padEnd(14))}${ticket} ${e.message}`;
12961
12919
  }
12962
- async function syncRouteName(i) {
12963
- const picked = pickShipRow(i.rows, i.machineId);
12964
- if (picked.kind !== "row") {
12965
- const status2 = picked.kind === "none" ? "no-row" : picked.kind;
12966
- return { status: status2, drift: "none", action: "none", name: i.local };
12967
- }
12968
- const rowName = (picked.row.name ?? "").trim();
12969
- if (!rowName) return { status: "no-row", drift: "none", action: "none", name: i.local };
12970
- const { drift, action } = planNameSync(i.local, rowName, i.lastSynced);
12971
- const base = { drift, rowId: picked.row.id, rowName };
12972
- if (action === "push") {
12973
- if (!i.rename) return { status: "ok", ...base, action, name: i.local };
12974
- try {
12975
- await i.rename(picked.row.id, i.local);
12976
- } catch (e) {
12977
- return { status: "refused", ...base, action: "none", name: i.local, error: e.message };
12920
+ function foldCycle(events) {
12921
+ const last = events[events.length - 1];
12922
+ if (!last) return null;
12923
+ const ofCycle = events.filter((e) => e.cycle === last.cycle);
12924
+ const first = ofCycle[0];
12925
+ const state = {
12926
+ cycle: last.cycle,
12927
+ route: last.route,
12928
+ step: last.step,
12929
+ role: last.role,
12930
+ ticket: [...ofCycle].reverse().find((e) => e.ticket && WORKING_STEPS.has(e.step))?.ticket,
12931
+ startedAt: first.at,
12932
+ lastAt: last.at,
12933
+ warnings: ofCycle.filter((e) => e.level === "warn").length,
12934
+ errors: ofCycle.filter((e) => e.level === "error").length
12935
+ };
12936
+ for (const e of ofCycle) {
12937
+ if (typeof e.data?.rank === "number") state.rank = e.data.rank;
12938
+ if (Array.isArray(e.data?.pending)) state.pending = e.data.pending;
12939
+ if (Array.isArray(e.data?.blocked)) state.blockedTickets = e.data.blocked;
12940
+ if (Array.isArray(e.data?.qaHeld)) state.qaHeldTickets = e.data.qaHeld;
12941
+ if (e.step === "release" && typeof e.data?.cycles === "number") {
12942
+ state.releaseBlockedCycles = e.data.cycles;
12943
+ state.releaseBlockedReason = typeof e.data.reason === "string" ? e.data.reason : void 0;
12978
12944
  }
12979
- return { status: "ok", ...base, action, name: i.local };
12980
12945
  }
12981
- if (action === "adopt" || action === "row-wins") return { status: "ok", ...base, action, name: rowName, from: i.local };
12982
- return { status: "ok", ...base, action, name: i.local };
12946
+ return state;
12983
12947
  }
12984
- function readSyncedName(resolvedFile) {
12985
- if (!existsSync25(resolvedFile)) return void 0;
12986
- try {
12987
- const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
12988
- return typeof v === "string" ? v : void 0;
12989
- } catch {
12990
- return void 0;
12991
- }
12948
+ function foldCycles(events) {
12949
+ const routes = [...new Set(events.map((e) => e.route))];
12950
+ return routes.map((r) => foldCycle(events.filter((e) => e.route === r))).filter((s) => s !== null).sort((a, b) => Date.parse(b.lastAt) - Date.parse(a.lastAt));
12992
12951
  }
12993
- function writeSyncedName(resolvedFile, name) {
12994
- if (!existsSync25(resolvedFile)) return;
12995
- let raw;
12996
- try {
12997
- raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
12998
- } catch {
12999
- return;
12952
+ function renderHeader(s, now = Date.now()) {
12953
+ if (!s) return dim("no cycle seen yet - waiting for the crew to run\n");
12954
+ const elapsed = Math.max(0, Math.round((now - Date.parse(s.lastAt)) / 1e3));
12955
+ const progress = STEP_ORDER.map((st) => st === s.step ? bold(green(st)) : dim(st)).join(dim(" > "));
12956
+ const bits = [
12957
+ `${bold(s.route)} cycle ${s.cycle}`,
12958
+ s.role ? `role ${bold(s.role)}` : null,
12959
+ s.ticket ? `ticket ${bold(s.ticket)}` : null,
12960
+ s.rank !== void 0 ? dim(`rank ${s.rank}`) : null,
12961
+ s.pending?.length ? dim(`pending: ${s.pending.join(" ")}`) : null,
12962
+ s.errors ? red(`${s.errors} error(s)`) : s.warnings ? yellow(`${s.warnings} warning(s)`) : null,
12963
+ dim(`${elapsed}s in ${s.step}`)
12964
+ ].filter(Boolean);
12965
+ const releaseLine = s.releaseBlockedCycles ? red(
12966
+ `release BLOCKED (${s.releaseBlockedCycles} cycle${s.releaseBlockedCycles === 1 ? "" : "s"}): ${s.releaseBlockedReason ?? "unknown reason"}`
12967
+ ) : null;
12968
+ const extra = [
12969
+ releaseLine,
12970
+ s.blockedTickets?.length ? dim(`blocked: ${s.blockedTickets.join(" ")}`) : null,
12971
+ s.qaHeldTickets?.length ? dim(`qa holding: ${s.qaHeldTickets.join(" ")}`) : null
12972
+ ].filter(Boolean);
12973
+ const extraLines = extra.length ? `${extra.join("\n")}
12974
+ ` : "";
12975
+ return `${bits.join(dim(" | "))}
12976
+ ${extraLines}${progress}
12977
+ ${dim("-".repeat(72))}
12978
+ `;
12979
+ }
12980
+ function renderHeaders(states, now = Date.now()) {
12981
+ if (!states.length) return dim("no cycle seen yet - waiting for the crew to run\n");
12982
+ return states.map((s) => renderHeader(s, now)).join("");
12983
+ }
12984
+ function readFrom(file, from) {
12985
+ if (!existsSync23(file)) return { events: [], offset: from };
12986
+ const size = statSync7(file).size;
12987
+ if (size < from) return readFrom(file, 0);
12988
+ if (size === from) return { events: [], offset: from };
12989
+ const fd = openSync4(file, "r");
12990
+ const buf = Buffer.alloc(size - from);
12991
+ readSync2(fd, buf, 0, buf.length, from);
12992
+ closeSync4(fd);
12993
+ const text = buf.toString("utf8");
12994
+ const lastNewline = text.lastIndexOf("\n");
12995
+ if (lastNewline === -1) return { events: [], offset: from };
12996
+ const events = [];
12997
+ for (const line of text.slice(0, lastNewline).split("\n")) {
12998
+ if (!line.trim()) continue;
12999
+ try {
13000
+ events.push(JSON.parse(line));
13001
+ } catch {
13002
+ }
13000
13003
  }
13001
- if (raw.shipName === name) return;
13002
- raw.shipName = name;
13003
- mkdirSync16(dirname14(resolvedFile), { recursive: true });
13004
- const tmp = `${resolvedFile}.tmp${process.pid}`;
13005
- writeFileSync18(tmp, `${JSON.stringify(raw, null, 2)}
13006
- `);
13007
- renameSync2(tmp, resolvedFile);
13004
+ return { events, offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1), "utf8") };
13008
13005
  }
13009
- async function reconcileShipName(i) {
13010
- let local = i.ship.name;
13006
+ function startWatch(o) {
13007
+ const out = o.out ?? ((s) => process.stdout.write(s));
13008
+ const filter = o.filter ?? {};
13009
+ let all = [];
13010
+ let offset = 0;
13011
+ let paused = false;
13012
+ const showRoute = () => !filter.route && new Set(all.map((e) => e.route)).size > 1;
13013
+ const seed = readFrom(o.file, 0);
13014
+ all = seed.events;
13015
+ offset = seed.offset;
13016
+ for (const e of all.slice(-(o.tail ?? 20)).filter((x) => matches(x, filter))) out(`${formatLine(e, showRoute())}
13017
+ `);
13018
+ out(renderHeaders(foldCycles(byRoute(all, filter.route))));
13019
+ const pump = () => {
13020
+ const { events, offset: next } = readFrom(o.file, offset);
13021
+ offset = next;
13022
+ if (!events.length) return;
13023
+ all = [...all, ...events].slice(-5e3);
13024
+ if (paused) return;
13025
+ for (const e of events) if (matches(e, filter)) out(`${formatLine(e, showRoute())}
13026
+ `);
13027
+ out(renderHeaders(foldCycles(byRoute(all, filter.route))));
13028
+ };
13029
+ const timer = setInterval(pump, 1e3);
13030
+ let watcher;
13011
13031
  try {
13012
- local = shipNameOfYaml(readFileSync22(i.configFile, "utf8")) ?? local;
13032
+ watcher = watchFile(o.file, { persistent: false }, pump);
13013
13033
  } catch {
13014
13034
  }
13015
- const resolvedFile = resolvedPathFor(i.stateDir, i.route);
13016
- const lastSynced = readSyncedName(resolvedFile);
13017
- let rows = [];
13018
- try {
13019
- rows = await i.rows();
13020
- } catch {
13035
+ const stdin = process.stdin;
13036
+ const stop = () => {
13037
+ clearInterval(timer);
13038
+ watcher?.close();
13039
+ if (stdin.isTTY) {
13040
+ stdin.setRawMode(false);
13041
+ stdin.pause();
13042
+ stdin.off("data", onKey);
13043
+ }
13044
+ };
13045
+ const onKey = (k) => {
13046
+ const s = k.toString();
13047
+ if (s === " ") {
13048
+ paused = !paused;
13049
+ out(dim(paused ? "\n-- paused (space to resume) --\n" : "-- resumed --\n"));
13050
+ }
13051
+ if (s === "q" || s === "") {
13052
+ stop();
13053
+ process.exit(0);
13054
+ }
13055
+ };
13056
+ if (stdin.isTTY) {
13057
+ stdin.setRawMode(true);
13058
+ stdin.resume();
13059
+ stdin.on("data", onKey);
13021
13060
  }
13022
- const result = await syncRouteName({
13023
- local,
13024
- lastSynced,
13025
- rows,
13026
- machineId: i.machineId,
13027
- rename: i.readOnly ? void 0 : i.rename
13028
- });
13029
- if (i.readOnly || result.status === "unsupported") return result;
13030
- if (result.status === "refused") {
13031
- i.emit?.warn(`could not rename the Ships row to "${local}": ${result.error}`, { step: "poll", data: { event: "ship_rename_refused", route: i.route, to: local } });
13032
- return result;
13061
+ return stop;
13062
+ }
13063
+
13064
+ // src/inbox.ts
13065
+ function matchMembers(rows, query) {
13066
+ const q = query.trim().toLowerCase();
13067
+ const asEmail = q.includes("@");
13068
+ return rows.filter((r) => asEmail ? (r.email ?? "").toLowerCase() === q : (r.name ?? "").toLowerCase().includes(q)).map((r) => ({ id: r.id, name: r.name ?? "", email: r.email ?? "", by: asEmail ? "email" : "name" }));
13069
+ }
13070
+ async function gatherInbox(routes, ship, opts = {}) {
13071
+ const items = [];
13072
+ const errors = [];
13073
+ const resolved = [];
13074
+ await Promise.all(routes.map(async (route) => {
13075
+ try {
13076
+ const tracker = new Tracker(route, ship);
13077
+ const [tickets, crewRows] = await Promise.all([tracker.openTickets(), tracker.crewRows()]);
13078
+ const roster = buildRoster(configuredMembers(route), crewRows);
13079
+ let mine;
13080
+ if (opts.memberQuery) {
13081
+ const hits = matchMembers(crewRows, opts.memberQuery);
13082
+ resolved.push({
13083
+ route: route.route,
13084
+ names: hits.map((h) => h.name),
13085
+ emails: hits.map((h) => h.email).filter(Boolean),
13086
+ by: hits[0]?.by ?? null
13087
+ });
13088
+ mine = new Set(hits.map((h) => h.id));
13089
+ } else {
13090
+ mine = opts.memberIds ?? new Set([route.resolved?.operator].filter(Boolean));
13091
+ }
13092
+ const c = tracker.contract;
13093
+ for (const t of tickets) {
13094
+ if (!t.assignee_id || !mine.has(t.assignee_id)) continue;
13095
+ if (opts.statuses && !opts.statuses.has(t.status)) continue;
13096
+ if (!opts.includeClosed && c.statuses.resolved.includes(t.status)) continue;
13097
+ items.push({
13098
+ route: route.route,
13099
+ ticket: t,
13100
+ assignee: crewLabel(roster.get(t.assignee_id)),
13101
+ rank: rankScalar(t, c),
13102
+ effective: effectivePriority(t, c),
13103
+ contract: c
13104
+ });
13105
+ }
13106
+ } catch (e) {
13107
+ errors.push({ route: route.route, error: e.message });
13108
+ }
13109
+ }));
13110
+ items.sort((a, b) => a.rank - b.rank);
13111
+ const byName = resolved.filter((r) => r.by === "name" && r.names.length);
13112
+ const distinct = new Set(byName.flatMap((r) => r.names));
13113
+ const ambiguous = byName.length > 1 && distinct.size > 1;
13114
+ return { items, errors, resolved, ambiguous };
13115
+ }
13116
+ function renderInbox(items, byConnection = false, subject) {
13117
+ if (items.length === 0) return `nothing assigned to ${subject ?? "you"}
13118
+ `;
13119
+ const key = (i) => byConnection ? i.route : i.ticket.status;
13120
+ const groups = /* @__PURE__ */ new Map();
13121
+ for (const i of items) {
13122
+ const g = groups.get(key(i));
13123
+ if (g) g.push(i);
13124
+ else groups.set(key(i), [i]);
13033
13125
  }
13034
- if (result.status !== "ok") return result;
13035
- if (result.action === "adopt" || result.action === "row-wins") {
13036
- const text = readFileSync22(i.configFile, "utf8");
13037
- writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
13038
- i.ship.name = result.name;
13039
- if (result.action === "row-wins") {
13040
- i.emit?.warn(
13041
- `ship name changed in both crew.yaml ("${result.from}") and the Ships table ("${result.name}"); the Ships row wins`,
13042
- { step: "poll", data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table", conflict: true } }
13126
+ const out = [];
13127
+ for (const [name, group] of groups) {
13128
+ out.push(`${name} (${group.length})`);
13129
+ for (const i of group) {
13130
+ const t = i.ticket;
13131
+ const where = byConnection ? t.status : i.route;
13132
+ out.push(
13133
+ ` ${displayKey(t).padEnd(10)} p${i.effective} ${(t.severity ?? "--").padEnd(3)} ${where.padEnd(14)} ${(t.title ?? "").slice(0, 62)}`
13043
13134
  );
13044
- } else {
13045
- i.emit?.emit(`ship renamed to "${result.name}" in the Ships table; crew.yaml updated`, {
13046
- step: "poll",
13047
- data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table" }
13048
- });
13049
13135
  }
13050
- } else if (result.action === "push") {
13051
- i.emit?.emit(`ship name "${result.name}" pushed to the Ships table`, {
13052
- step: "poll",
13053
- data: { event: "ship_renamed", route: i.route, from: result.rowName, to: result.name, source: "crew.yaml" }
13054
- });
13055
- }
13056
- writeSyncedName(resolvedFile, result.name);
13057
- return result;
13058
- }
13059
- async function reconcileRouteShipName(o) {
13060
- const fallback = { status: "unsupported", drift: "none", action: "none", name: o.ship.name };
13061
- if (!o.route.resolved?.shipsModelId) return fallback;
13062
- try {
13063
- const machineId = o.machineId ?? ensureMachineId(o.ship.stateDir);
13064
- const tracker = new Tracker(o.route, o.ship);
13065
- return await reconcileShipName({
13066
- route: o.route.route,
13067
- configFile: o.configFile,
13068
- stateDir: o.ship.stateDir,
13069
- ship: o.ship,
13070
- machineId,
13071
- rows: () => tracker.shipRows(),
13072
- rename: (id, name) => tracker.renameShipRow(id, name),
13073
- emit: o.emit,
13074
- readOnly: o.readOnly
13075
- });
13076
- } catch {
13077
- return fallback;
13136
+ out.push("");
13078
13137
  }
13138
+ return out.join("\n");
13079
13139
  }
13080
13140
 
13081
- // src/connect.ts
13082
- var ConnectHttpError = class extends Error {
13083
- status;
13084
- path;
13085
- constructor(status2, path, statusText) {
13086
- super(`${path}: ${status2} ${statusText}`);
13087
- this.name = "ConnectHttpError";
13088
- this.status = status2;
13089
- this.path = path;
13141
+ // src/fleet.ts
13142
+ function agedConnectionRank(rawRank, role, route, decision, state, now) {
13143
+ if (role !== "dev" && role !== "design") return rawRank;
13144
+ const waiting = state.fairness(route.route).waiting();
13145
+ if (!waiting || waiting.ticket !== decision.actionable.top?.issue_id) return rawRank;
13146
+ return agedRankScalar(rawRank, { since: waiting.since, weight: route.weight }, now);
13147
+ }
13148
+ async function decideFleet(o) {
13149
+ const now = o.now ?? Date.now();
13150
+ const eligible = o.routes.filter((c) => o.enabledOnly === false ? true : c.enabled);
13151
+ const entries = await Promise.all(eligible.map(async (route) => {
13152
+ try {
13153
+ const decision = await decideCycle({ route, ship: o.ship, state: o.state, emit: o.emit.forRoute(route.route) });
13154
+ const sel = decision.selection;
13155
+ if (!sel.selected) return { route, decision, rank: NOTHING_ACTIONABLE };
13156
+ const rawRank = sel.ranks[sel.selected] ?? NOTHING_ACTIONABLE;
13157
+ return {
13158
+ route,
13159
+ decision,
13160
+ role: sel.selected,
13161
+ rank: agedConnectionRank(rawRank, sel.selected, route, decision, o.state, now)
13162
+ };
13163
+ } catch (e) {
13164
+ return { route, rank: NOTHING_ACTIONABLE, error: e.message };
13165
+ }
13166
+ }));
13167
+ for (const e of entries) {
13168
+ if (e.error) o.emit.forRoute(e.route.route).error(`${e.route.route}: ${e.error}`, { step: "poll" });
13090
13169
  }
13091
- };
13092
- async function get(o, path) {
13093
- const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13094
- headers: {
13095
- Authorization: `Bearer ${o.apiKey}`,
13096
- // Cloudflare 403s default agents on this host.
13097
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
13170
+ const contenders = entries.filter((e) => e.role && e.rank < NOTHING_ACTIONABLE);
13171
+ const best = contenders.reduce(
13172
+ (acc, e) => !acc || e.rank < acc.rank ? e : acc,
13173
+ void 0
13174
+ );
13175
+ for (const e of entries) {
13176
+ if (e.error || !e.decision) continue;
13177
+ const won = best?.route.route === e.route.route;
13178
+ o.state.fairness(e.route.route).record(e.decision.actionable.count, e.decision.actionable.top?.issue_id, won);
13179
+ if (!o.dryRun) {
13180
+ o.state.attention(e.route.route).persist(e.decision.attention);
13181
+ o.state.unroutable(e.route.route).persist(e.decision.unroutable);
13098
13182
  }
13099
- });
13100
- if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13101
- return await res.json();
13102
- }
13103
- async function post2(o, path, body) {
13104
- const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13105
- method: "POST",
13106
- headers: {
13107
- Authorization: `Bearer ${o.apiKey}`,
13108
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
13109
- "Content-Type": "application/json"
13110
- },
13111
- body: JSON.stringify(body)
13112
- });
13113
- if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13114
- return await res.json();
13183
+ }
13184
+ return {
13185
+ entries,
13186
+ winner: best && best.role && best.decision ? { route: best.route, role: best.role, decision: best.decision } : void 0,
13187
+ unreachable: entries.filter((e) => e.error).map((e) => e.route.route)
13188
+ };
13115
13189
  }
13116
- async function patch(o, path, body) {
13117
- const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13118
- method: "PATCH",
13119
- headers: {
13120
- Authorization: `Bearer ${o.apiKey}`,
13121
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
13122
- "Content-Type": "application/json"
13123
- },
13124
- body: JSON.stringify(body)
13125
- });
13126
- if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13127
- return await res.json();
13190
+ var LOUD_STREAK = 5;
13191
+ function since(iso, now = Date.now()) {
13192
+ const ms2 = Math.max(0, now - Date.parse(iso));
13193
+ const mins = Math.round(ms2 / 6e4);
13194
+ if (mins < 60) return `${mins}m`;
13195
+ const hours = Math.round(mins / 60);
13196
+ if (hours < 48) return `${hours}h`;
13197
+ return `${Math.round(hours / 24)}d`;
13128
13198
  }
13129
- async function del(o, path) {
13130
- const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13131
- method: "DELETE",
13132
- headers: {
13133
- Authorization: `Bearer ${o.apiKey}`,
13134
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
13135
- }
13199
+ function renderFleet(f, state) {
13200
+ const rows = f.entries.map((e) => {
13201
+ if (e.error) return ` ${e.route.route.padEnd(16)} unreachable \u2014 ${e.error}`;
13202
+ if (!e.role) return ` ${e.route.route.padEnd(16)} nothing pending`;
13203
+ const pending = e.decision?.selection.pending.join(" ") ?? "";
13204
+ const win = f.winner?.route.route === e.route.route ? " <- runs this cycle" : "";
13205
+ const streak = state?.fairness(e.route.route).streak() ?? 0;
13206
+ const passedOver = !win && streak >= LOUD_STREAK ? ` ! passed over ${streak} cycles running` : "";
13207
+ return ` ${e.route.route.padEnd(16)} ${e.role.padEnd(7)} rank ${String(e.rank).padStart(12)} pending: ${pending}${win}${passedOver}`;
13136
13208
  });
13137
- if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13209
+ if (!rows.length) return "no enabled routes\n";
13210
+ return `${rows.join("\n")}
13211
+ `;
13138
13212
  }
13139
- async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
13140
- const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
13141
- if (scopes.length === 0) return [];
13142
- const client2 = {
13143
- // The Locks table is a handful of rows, so read it whole and match in
13144
- // memory rather than depend on the HTTP filter syntax.
13145
- list: async (model, params) => {
13146
- const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
13147
- const rows = await get(
13148
- o,
13149
- `/data-models/${model}/records?limit=500`
13150
- );
13151
- return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
13152
- },
13153
- create: (model, body) => post2(o, `/data-models/${model}/records`, body),
13154
- remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
13213
+ function snapshot(tickets) {
13214
+ return {
13215
+ state: new Map(tickets.map((t) => [t.id, `${t.status}|${t.assignee_id ?? ""}|${t.updated_at}`]))
13155
13216
  };
13156
- return ensureLockRows(client2, locksModelId, scopes);
13157
13217
  }
13158
- var ISSUES_TEMPLATE_IDENTIFIERS = ["tablation.issues", "crew.issues"];
13159
- async function resolveIssuesTemplateKeys(o) {
13160
- const keys = new Set(ISSUES_TEMPLATE_IDENTIFIERS);
13161
- await Promise.all(ISSUES_TEMPLATE_IDENTIFIERS.map(async (identifier) => {
13162
- try {
13163
- const t = await get(
13164
- o,
13165
- `/library-templates/by-identifier/${encodeURIComponent(identifier)}`
13166
- );
13167
- if (t.id) keys.add(t.id);
13168
- if (t.identifier) keys.add(t.identifier);
13169
- } catch {
13170
- }
13171
- }));
13172
- return keys;
13218
+ function changed(before, after) {
13219
+ if (before.state.size !== after.state.size) return true;
13220
+ for (const [id, v] of after.state) if (before.state.get(id) !== v) return true;
13221
+ return false;
13173
13222
  }
13174
- async function listLibraryTemplates(o) {
13175
- return get(o, "/library-templates");
13223
+ function nextRoles(decision, ran) {
13224
+ return decision.selection.pending.filter((r) => !ran.includes(r)).sort((a, b) => (decision.selection.ranks[a] ?? 0) - (decision.selection.ranks[b] ?? 0));
13176
13225
  }
13177
- async function previewTemplateInstall(o, templateId, workspaceId) {
13178
- return get(o, `/library-templates/${templateId}/install-preview?workspaceId=${workspaceId}`);
13226
+
13227
+ // src/ship-name.ts
13228
+ import { existsSync as existsSync25, mkdirSync as mkdirSync16, readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
13229
+ import { dirname as dirname14 } from "path";
13230
+ import { parseDocument } from "yaml";
13231
+
13232
+ // src/machine-id.ts
13233
+ import { execFileSync as execFileSync4 } from "child_process";
13234
+ import { createHash as createHash4 } from "crypto";
13235
+ import { existsSync as existsSync24, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs";
13236
+ import { dirname as dirname13, join as join27 } from "path";
13237
+ function machineIdPathFor(stateDir) {
13238
+ return join27(stateDir, "machine-id");
13179
13239
  }
13180
- async function installTemplate(o, templateId, workspaceId, projectName) {
13181
- return post2(o, `/library-templates/${templateId}/install`, {
13182
- workspaceId,
13183
- ...projectName ? { projectName } : {}
13184
- });
13240
+ var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
13241
+ function readRawMachineId(platform2 = process.platform) {
13242
+ try {
13243
+ if (platform2 === "darwin") {
13244
+ const out = execFileSync4("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
13245
+ encoding: "utf8",
13246
+ stdio: ["ignore", "pipe", "ignore"]
13247
+ });
13248
+ return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
13249
+ }
13250
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
13251
+ if (existsSync24(p)) {
13252
+ const id = readFileSync21(p, "utf8").trim();
13253
+ if (id) return id;
13254
+ }
13255
+ }
13256
+ } catch {
13257
+ }
13258
+ return void 0;
13185
13259
  }
13186
- async function listWorkspaces(o) {
13187
- const res = await get(
13188
- o,
13189
- "/auth/my-workspaces"
13190
- );
13191
- return res.workspaces.map((w) => ({ slug: w.path ?? w.slug, name: w.name, role: w.role }));
13260
+ function ensureMachineId(stateDir, opts = {}) {
13261
+ if (opts.override?.trim()) return sha256(opts.override.trim());
13262
+ const path = machineIdPathFor(stateDir);
13263
+ if (existsSync24(path)) {
13264
+ const cached = readFileSync21(path, "utf8").trim();
13265
+ if (/^[0-9a-f]{64}$/.test(cached)) return cached;
13266
+ }
13267
+ const raw = (opts.read ?? readRawMachineId)();
13268
+ if (!raw) return void 0;
13269
+ const hashed = sha256(raw);
13270
+ mkdirSync15(dirname13(path), { recursive: true });
13271
+ writeFileSync17(path, `${hashed}
13272
+ `, { mode: 384 });
13273
+ return hashed;
13192
13274
  }
13193
- var eq = (a, b) => (a ?? "").trim().toLowerCase() === b.trim().toLowerCase();
13194
- function seatFor(rows, role) {
13195
- const r = rows.find((x) => (x.name ?? "").toLowerCase().includes(role.toLowerCase()));
13196
- return r?.id;
13275
+ function matchShipRows(ships, shipName2, machineId) {
13276
+ const hasColumn = ships.some((r) => "machine_id" in r);
13277
+ const byId = hasColumn && machineId ? ships.filter((r) => r.machine_id === machineId) : [];
13278
+ if (byId.length > 0) return { rows: byId, by: "machine_id", hasColumn };
13279
+ return { rows: ships.filter((r) => (r.name ?? "").trim() === shipName2.trim()), by: "name", hasColumn };
13197
13280
  }
13198
- var REQUIRED_TABLES = ["Issues"];
13199
- async function missingTables(o, projectId) {
13200
- const models = await get(o, `/data-models?projectId=${projectId}`);
13201
- return REQUIRED_TABLES.filter((t) => !models.some((m) => eq(m.name, t)));
13281
+
13282
+ // src/ship-name.ts
13283
+ var ShipNameError = class extends Error {
13284
+ };
13285
+ function validateShipName(raw) {
13286
+ const name = raw.trim();
13287
+ if (!name) throw new ShipNameError("the ship name cannot be empty");
13288
+ if (/[\u0000-\u001f\u007f]/.test(name)) throw new ShipNameError("the ship name cannot contain control characters");
13289
+ return name;
13202
13290
  }
13203
- async function discover(o) {
13204
- const problems = [];
13205
- const provisioning = [];
13206
- const ws = await get(o, `/workspaces/${encodeURIComponent(o.workspace).replace(/%3A/gi, ":")}`);
13207
- const out = {
13208
- workspaceId: ws.id,
13209
- workspaceSlug: ws.path ?? ws.slug,
13210
- workspaceName: ws.name,
13211
- models: {},
13212
- seats: {},
13213
- holds: [],
13214
- candidates: [],
13215
- problems,
13216
- provisioning
13217
- };
13218
- try {
13219
- const me = await get(o, `/auth/me?workspaceId=${ws.id}`);
13220
- out.role = me.role;
13221
- out.meEmail = me.email;
13222
- out.meId = me.id;
13223
- out.meIdentityId = me.identityId;
13224
- out.meName = me.name;
13225
- } catch {
13291
+ function planNameSync(local, row, lastSynced) {
13292
+ if (local === row) return { drift: "none", action: "none" };
13293
+ const rowMoved = lastSynced === void 0 || row !== lastSynced;
13294
+ const localMoved = lastSynced === void 0 || local !== lastSynced;
13295
+ if (rowMoved && localMoved) return { drift: "both", action: "row-wins" };
13296
+ if (rowMoved) return { drift: "row", action: "adopt" };
13297
+ return { drift: "local", action: "push" };
13298
+ }
13299
+ function shipNameOfYaml(text) {
13300
+ const v = parseDocument(text).getIn(["ship", "name"]);
13301
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
13302
+ }
13303
+ function setShipNameInYaml(text, name) {
13304
+ const doc = parseDocument(text);
13305
+ doc.setIn(["ship", "name"], name);
13306
+ return doc.toString();
13307
+ }
13308
+ function pickShipRow(rows, machineId, byName = []) {
13309
+ const hasColumn = rows.some((r) => "machine_id" in r);
13310
+ if (!hasColumn || !machineId) return { kind: "unsupported" };
13311
+ const byId = rows.filter((r) => r.machine_id === machineId);
13312
+ if (byId.length === 1) return { kind: "row", row: byId[0], by: "machine_id" };
13313
+ if (byId.length > 1) return { kind: "ambiguous" };
13314
+ const names = new Set(byName.map((n) => n.trim()));
13315
+ const named = rows.filter((r) => !r.machine_id && names.has((r.name ?? "").trim()));
13316
+ if (named.length === 1) return { kind: "row", row: named[0], by: "name" };
13317
+ return { kind: named.length > 1 ? "ambiguous" : "none" };
13318
+ }
13319
+ async function syncRouteName(i) {
13320
+ const picked = pickShipRow(i.rows, i.machineId);
13321
+ if (picked.kind !== "row") {
13322
+ const status2 = picked.kind === "none" ? "no-row" : picked.kind;
13323
+ return { status: status2, drift: "none", action: "none", name: i.local };
13226
13324
  }
13227
- if (o.project) {
13325
+ const rowName = (picked.row.name ?? "").trim();
13326
+ if (!rowName) return { status: "no-row", drift: "none", action: "none", name: i.local };
13327
+ const { drift, action } = planNameSync(i.local, rowName, i.lastSynced);
13328
+ const base = { drift, rowId: picked.row.id, rowName };
13329
+ if (action === "push") {
13330
+ if (!i.rename) return { status: "ok", ...base, action, name: i.local };
13228
13331
  try {
13229
- const p = await get(
13230
- o,
13231
- `/projects/${encodeURIComponent(o.project)}?workspaceId=${ws.id}`
13232
- );
13233
- out.projectId = p.id;
13234
- out.projectSlug = p.slug;
13235
- out.projectName = p.name;
13236
- } catch {
13237
- const projects = await get(o, `/projects?workspaceId=${ws.id}`);
13238
- const hit = projects.find((p) => eq(p.name, o.project));
13239
- if (hit) {
13240
- out.projectId = hit.id;
13241
- out.projectSlug = hit.slug;
13242
- out.projectName = hit.name;
13243
- } else problems.push(`no project named or slugged "${o.project}" \u2014 have: ${projects.map((p) => p.name).join(", ") || "(none)"}`);
13244
- }
13245
- } else {
13246
- const projects = await get(o, `/projects?workspaceId=${ws.id}`);
13247
- const issuesKeys = await resolveIssuesTemplateKeys(o);
13248
- const defaultCandidate = projects.find((p) => !!p.sourceTemplateId && issuesKeys.has(p.sourceTemplateId)) ?? projects.find((p) => eq(p.slug ?? "", "issues"));
13249
- if (defaultCandidate && (await missingTables(o, defaultCandidate.id)).length === 0) {
13250
- out.projectId = defaultCandidate.id;
13251
- out.projectSlug = defaultCandidate.slug;
13252
- out.projectName = defaultCandidate.name;
13253
- } else {
13254
- const options = await Promise.all(
13255
- projects.map(async (p) => ({ id: p.id, name: p.name, slug: p.slug, missing: await missingTables(o, p.id) }))
13256
- );
13257
- const qualifying = options.filter((p) => p.missing.length === 0);
13258
- if (qualifying.length === 1) {
13259
- out.projectId = qualifying[0].id;
13260
- out.projectSlug = qualifying[0].slug;
13261
- out.projectName = qualifying[0].name;
13262
- } else if (qualifying.length > 1) {
13263
- out.projectOptions = qualifying;
13264
- problems.push(
13265
- `${qualifying.length} projects in this workspace all have the tables a route needs \u2014 name one: ${qualifying.map((p) => p.slug ?? p.name).join(", ")}`
13266
- );
13267
- } else {
13268
- const isAdmin = out.role === "WORKSPACE_ADMIN" || out.role === "PLATFORM_ADMIN";
13269
- out.offerTemplateInstall = isAdmin;
13270
- problems.push(
13271
- `no project in this workspace has the tables a route needs (${REQUIRED_TABLES.join(", ")}) \u2014 ` + (isAdmin ? "install an Issues-tracker template for this workspace (Library, in the app), then rerun." : "ask a workspace admin to set one up, then rerun.")
13272
- );
13273
- }
13332
+ await i.rename(picked.row.id, i.local);
13333
+ } catch (e) {
13334
+ return { status: "refused", ...base, action: "none", name: i.local, error: e.message };
13274
13335
  }
13336
+ return { status: "ok", ...base, action, name: i.local };
13275
13337
  }
13276
- if (!out.projectId) return out;
13277
- const models = await get(o, `/data-models?projectId=${out.projectId}`);
13278
- const find = (name) => models.find((m) => eq(m.name, name))?.id;
13279
- for (const [key, name] of [["issues", "Issues"], ["comments", "Comments"], ["crew", "Crew"]]) {
13280
- const id = find(name);
13281
- if (id) out.models[key] = id;
13282
- else problems.push(`no "${name}" table \u2014 have: ${models.map((m) => m.name).join(", ")}`);
13338
+ if (action === "adopt" || action === "row-wins") return { status: "ok", ...base, action, name: rowName, from: i.local };
13339
+ return { status: "ok", ...base, action, name: i.local };
13340
+ }
13341
+ function readSyncedName(resolvedFile) {
13342
+ if (!existsSync25(resolvedFile)) return void 0;
13343
+ try {
13344
+ const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
13345
+ return typeof v === "string" ? v : void 0;
13346
+ } catch {
13347
+ return void 0;
13283
13348
  }
13284
- if (out.models.issues) {
13285
- try {
13286
- const issuesModel = await get(o, `/data-models/${out.models.issues}`);
13287
- const statusField = issuesModel.fields.find((f) => f.columnName === DEFAULT_CONTRACT.columns.status);
13288
- const known = /* @__PURE__ */ new Set([
13289
- ...DEFAULT_CONTRACT.statuses.open,
13290
- ...DEFAULT_CONTRACT.statuses.resolved,
13291
- DEFAULT_CONTRACT.statuses.draft
13292
- ]);
13293
- out.unrecognizedStatuses = (statusField?.fieldType?.choiceOptions ?? []).filter((opt) => !known.has(opt.value)).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)).map((opt) => ({ value: opt.value, label: opt.label ?? void 0, position: opt.position ?? void 0 }));
13294
- } catch {
13295
- }
13349
+ }
13350
+ function writeSyncedName(resolvedFile, name) {
13351
+ if (!existsSync25(resolvedFile)) return;
13352
+ let raw;
13353
+ try {
13354
+ raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
13355
+ } catch {
13356
+ return;
13296
13357
  }
13297
- if (out.models.issues) {
13298
- try {
13299
- await get(o, `/data-models/${out.models.issues}/events?limit=1`);
13300
- } catch (e) {
13301
- if (e instanceof ConnectHttpError && e.status === 404) problems.push(EVENT_LOG_MISSING);
13302
- }
13358
+ if (raw.shipName === name) return;
13359
+ raw.shipName = name;
13360
+ mkdirSync16(dirname14(resolvedFile), { recursive: true });
13361
+ const tmp = `${resolvedFile}.tmp${process.pid}`;
13362
+ writeFileSync18(tmp, `${JSON.stringify(raw, null, 2)}
13363
+ `);
13364
+ renameSync2(tmp, resolvedFile);
13365
+ }
13366
+ async function reconcileShipName(i) {
13367
+ let local = i.ship.name;
13368
+ try {
13369
+ local = shipNameOfYaml(readFileSync22(i.configFile, "utf8")) ?? local;
13370
+ } catch {
13303
13371
  }
13304
- out.areaModelId = find("Projects");
13305
- out.reposModelId = find("Repos");
13306
- out.shipsModelId = find("Ships");
13307
- out.epicsModelId = find("Epics");
13308
- out.locksModelId = find("Locks");
13372
+ const resolvedFile = resolvedPathFor(i.stateDir, i.route);
13373
+ const lastSynced = readSyncedName(resolvedFile);
13374
+ let rows = [];
13309
13375
  try {
13310
- const views = await get(o, `/views?workspaceId=${ws.id}`);
13311
- const candidates = views.filter((v) => v.recordLinkOnly === true && v.published === true && !!v.publicSlug && (v.pages ?? []).some((p) => (p.components ?? []).some((c) => c.dataModelId === out.models.issues)));
13312
- if (candidates.length === 1) {
13313
- out.recordLinkViewId = candidates[0].id;
13314
- } else if (candidates.length === 0) {
13315
- problems.push("no record-link-only view bound to Issues \u2014 attention events will carry no link");
13316
- } else {
13317
- problems.push(
13318
- `${candidates.length} record-link-only views bound to Issues \u2014 set resolved.recordLinkViewId by hand`
13319
- );
13320
- }
13376
+ rows = await i.rows();
13321
13377
  } catch {
13322
13378
  }
13323
- if (out.reposModelId) {
13324
- const repos = await get(
13325
- o,
13326
- `/data-models/${out.reposModelId}/records?limit=200`
13327
- );
13328
- out.repoNames = Object.fromEntries(repos.map((r) => [r.id, r.name ?? ""]));
13329
- const remotes = Object.fromEntries(
13330
- repos.filter((r) => r.remote).map((r) => [r.id, r.remote])
13331
- );
13332
- if (Object.keys(remotes).length > 0) out.repoRemotes = remotes;
13379
+ const result = await syncRouteName({
13380
+ local,
13381
+ lastSynced,
13382
+ rows,
13383
+ machineId: i.machineId,
13384
+ rename: i.readOnly ? void 0 : i.rename
13385
+ });
13386
+ if (i.readOnly || result.status === "unsupported") return result;
13387
+ if (result.status === "refused") {
13388
+ i.emit?.warn(`could not rename the Ships row to "${local}": ${result.error}`, { step: "poll", data: { event: "ship_rename_refused", route: i.route, to: local } });
13389
+ return result;
13333
13390
  }
13334
- if (o.area) {
13335
- if (!out.areaModelId) problems.push(`--area given but this project has no "Projects" table`);
13336
- else {
13337
- const areas = await get(o, `/data-models/${out.areaModelId}/records?limit=200`);
13338
- const hit = areas.find((a) => eq(a.name, o.area));
13339
- if (hit) {
13340
- out.areaId = hit.id;
13341
- out.areaName = hit.name;
13342
- } else problems.push(`no area named "${o.area}" \u2014 have: ${areas.map((a) => a.name).join(", ")}`);
13391
+ if (result.status !== "ok") return result;
13392
+ if (result.action === "adopt" || result.action === "row-wins") {
13393
+ const text = readFileSync22(i.configFile, "utf8");
13394
+ writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
13395
+ i.ship.name = result.name;
13396
+ if (result.action === "row-wins") {
13397
+ i.emit?.warn(
13398
+ `ship name changed in both crew.yaml ("${result.from}") and the Ships table ("${result.name}"); the Ships row wins`,
13399
+ { step: "poll", data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table", conflict: true } }
13400
+ );
13401
+ } else {
13402
+ i.emit?.emit(`ship renamed to "${result.name}" in the Ships table; crew.yaml updated`, {
13403
+ step: "poll",
13404
+ data: { event: "ship_renamed", route: i.route, from: result.from, to: result.name, source: "table" }
13405
+ });
13343
13406
  }
13407
+ } else if (result.action === "push") {
13408
+ i.emit?.emit(`ship name "${result.name}" pushed to the Ships table`, {
13409
+ step: "poll",
13410
+ data: { event: "ship_renamed", route: i.route, from: result.rowName, to: result.name, source: "crew.yaml" }
13411
+ });
13344
13412
  }
13345
- let shipRowId;
13346
- if (out.shipsModelId && o.ship && !o.dryRun) {
13347
- const shipRows = await get(
13348
- o,
13349
- `/data-models/${out.shipsModelId}/records?limit=200`
13350
- );
13351
- const hostPassengers = o.ship.hostPassengers ?? false;
13352
- const sshPublicKey = o.ship.sshPublicKey;
13353
- let shipColumns = /* @__PURE__ */ new Set();
13354
- try {
13355
- const shipsModel = await get(o, `/data-models/${out.shipsModelId}`);
13356
- shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
13357
- } catch {
13358
- }
13359
- let ownerId;
13360
- if (out.meId) {
13361
- if (shipColumns.has("owner_id")) ownerId = out.meId;
13362
- else provisioning.push("could not record the ship owner \u2014 the Ships table has no owner_id column yet (update the Issues template)");
13363
- }
13364
- const machineId = shipColumns.has("machine_id") ? o.ship.machineId : void 0;
13365
- let mine;
13366
- let matchedBy = "name";
13367
- let writeMachineId = false;
13368
- const byName = shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim());
13369
- if (machineId) {
13370
- const byId = shipRows.filter((r) => (r.machine_id ?? "") === machineId);
13371
- if (byId.length > 0) {
13372
- mine = byId;
13373
- matchedBy = "machine_id";
13374
- } else {
13375
- mine = byName.filter((r) => !r.machine_id);
13376
- writeMachineId = true;
13377
- }
13378
- } else {
13379
- mine = byName;
13413
+ writeSyncedName(resolvedFile, result.name);
13414
+ return result;
13415
+ }
13416
+ async function reconcileRouteShipName(o) {
13417
+ const fallback = { status: "unsupported", drift: "none", action: "none", name: o.ship.name };
13418
+ if (!o.route.resolved?.shipsModelId) return fallback;
13419
+ try {
13420
+ const machineId = o.machineId ?? ensureMachineId(o.ship.stateDir);
13421
+ const tracker = new Tracker(o.route, o.ship);
13422
+ return await reconcileShipName({
13423
+ route: o.route.route,
13424
+ configFile: o.configFile,
13425
+ stateDir: o.ship.stateDir,
13426
+ ship: o.ship,
13427
+ machineId,
13428
+ rows: () => tracker.shipRows(),
13429
+ rename: (id, name) => tracker.renameShipRow(id, name),
13430
+ emit: o.emit,
13431
+ readOnly: o.readOnly
13432
+ });
13433
+ } catch {
13434
+ return fallback;
13435
+ }
13436
+ }
13437
+
13438
+ // src/connect.ts
13439
+ var ConnectHttpError = class extends Error {
13440
+ status;
13441
+ path;
13442
+ constructor(status2, path, statusText) {
13443
+ super(`${path}: ${status2} ${statusText}`);
13444
+ this.name = "ConnectHttpError";
13445
+ this.status = status2;
13446
+ this.path = path;
13447
+ }
13448
+ };
13449
+ async function get(o, path) {
13450
+ const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13451
+ headers: {
13452
+ Authorization: `Bearer ${o.apiKey}`,
13453
+ // Cloudflare 403s default agents on this host.
13454
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
13380
13455
  }
13381
- if (mine.length === 1) {
13382
- shipRowId = mine[0].id;
13383
- out.shipRow = { id: shipRowId, name: o.ship.name, action: "matched", matchedBy };
13384
- const patchBody = {};
13385
- if (matchedBy === "machine_id") {
13386
- const rowName = (mine[0].name ?? "").trim();
13387
- const route = `${out.workspaceSlug}/${out.projectSlug ?? out.projectId}`;
13388
- const plan = planNameSync(o.ship.name.trim(), rowName, o.ship.lastSyncedName?.(route));
13389
- if (plan.action === "push") {
13390
- patchBody.name = o.ship.name.trim();
13391
- provisioning.push(`renamed Ships row "${rowName}" to "${o.ship.name.trim()}" (crew.yaml changed)`);
13392
- } else if (plan.action === "adopt" || plan.action === "row-wins") {
13393
- out.shipRow.name = rowName;
13394
- out.shipNameAdopted = rowName;
13395
- provisioning.push(`matched Ships row "${rowName}" by machine id \u2014 this host's name becomes "${rowName}"${plan.action === "row-wins" ? " (crew.yaml also changed; the Ships row wins)" : ""}`);
13396
- }
13397
- patchBody.last_seen = (/* @__PURE__ */ new Date()).toISOString();
13398
- }
13399
- if (writeMachineId) patchBody.machine_id = machineId;
13400
- if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
13401
- if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
13402
- patchBody.ssh_public_key = sshPublicKey;
13403
- }
13404
- if (ownerId !== void 0 && (mine[0].owner_id ?? "") !== ownerId) patchBody.owner_id = ownerId;
13405
- if (Object.keys(patchBody).length > 0) {
13406
- await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
13407
- if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
13408
- if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
13409
- if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
13410
- }
13411
- } else if (mine.length === 0) {
13412
- const created = await post2(o, `/data-models/${out.shipsModelId}/records`, {
13413
- name: o.ship.name,
13414
- ...o.ship.platform ? { platform: o.ship.platform } : {},
13415
- host_passengers: hostPassengers,
13416
- ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
13417
- ...ownerId !== void 0 ? { owner_id: ownerId } : {},
13418
- ...writeMachineId ? { machine_id: machineId } : {}
13419
- });
13420
- shipRowId = created.id;
13421
- out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
13422
- provisioning.push(`created Ships row "${o.ship.name}"`);
13423
- } else {
13424
- problems.push(
13425
- matchedBy === "machine_id" ? `${mine.length} Ships rows carry this machine's id \u2014 which one is this machine?` : `${mine.length} Ships rows named "${o.ship.name}" \u2014 which one is this machine?`
13426
- );
13456
+ });
13457
+ if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13458
+ return await res.json();
13459
+ }
13460
+ async function post2(o, path, body) {
13461
+ const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13462
+ method: "POST",
13463
+ headers: {
13464
+ Authorization: `Bearer ${o.apiKey}`,
13465
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
13466
+ "Content-Type": "application/json"
13467
+ },
13468
+ body: JSON.stringify(body)
13469
+ });
13470
+ if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13471
+ return await res.json();
13472
+ }
13473
+ async function patch(o, path, body) {
13474
+ const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13475
+ method: "PATCH",
13476
+ headers: {
13477
+ Authorization: `Bearer ${o.apiKey}`,
13478
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0",
13479
+ "Content-Type": "application/json"
13480
+ },
13481
+ body: JSON.stringify(body)
13482
+ });
13483
+ if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13484
+ return await res.json();
13485
+ }
13486
+ async function del(o, path) {
13487
+ const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
13488
+ method: "DELETE",
13489
+ headers: {
13490
+ Authorization: `Bearer ${o.apiKey}`,
13491
+ "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
13427
13492
  }
13428
- }
13429
- let agentIdFor = () => void 0;
13430
- if (shipRowId) {
13493
+ });
13494
+ if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
13495
+ }
13496
+ async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
13497
+ const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
13498
+ if (scopes.length === 0) return [];
13499
+ const client2 = {
13500
+ // The Locks table is a handful of rows, so read it whole and match in
13501
+ // memory rather than depend on the HTTP filter syntax.
13502
+ list: async (model, params) => {
13503
+ const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
13504
+ const rows = await get(
13505
+ o,
13506
+ `/data-models/${model}/records?limit=500`
13507
+ );
13508
+ return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
13509
+ },
13510
+ create: (model, body) => post2(o, `/data-models/${model}/records`, body),
13511
+ remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
13512
+ };
13513
+ return ensureLockRows(client2, locksModelId, scopes);
13514
+ }
13515
+ var ISSUES_TEMPLATE_IDENTIFIERS = ["tablation.issues", "crew.issues"];
13516
+ async function resolveIssuesTemplateKeys(o) {
13517
+ const keys = new Set(ISSUES_TEMPLATE_IDENTIFIERS);
13518
+ await Promise.all(ISSUES_TEMPLATE_IDENTIFIERS.map(async (identifier) => {
13431
13519
  try {
13432
- const agentsModel = await get(o, `/data-models/agents?workspaceId=${ws.id}`);
13433
- const agentRows = await get(
13520
+ const t = await get(
13434
13521
  o,
13435
- `/data-models/${agentsModel.id}/records?limit=200`
13522
+ `/library-templates/by-identifier/${encodeURIComponent(identifier)}`
13436
13523
  );
13437
- agentIdFor = (role) => agentRows.find((r) => r.name === PERSONA_NAME[role])?.id;
13524
+ if (t.id) keys.add(t.id);
13525
+ if (t.identifier) keys.add(t.identifier);
13438
13526
  } catch {
13439
13527
  }
13440
- }
13441
- if (out.models.crew) {
13442
- const rows = await get(
13443
- o,
13444
- `/data-models/${out.models.crew}/records?limit=200`
13445
- );
13446
- let seatShipId = shipRowId;
13447
- if (!seatShipId && o.dryRun && o.ship && out.shipsModelId) {
13448
- try {
13449
- const shipRows = await get(
13450
- o,
13451
- `/data-models/${out.shipsModelId}/records?limit=200`
13452
- );
13453
- const byId = o.ship.machineId ? shipRows.filter((r) => r.machine_id === o.ship.machineId) : [];
13454
- const mineRows = byId.length > 0 ? byId : shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim() && !r.machine_id);
13455
- if (mineRows.length === 1) seatShipId = mineRows[0].id;
13456
- } catch {
13457
- }
13458
- }
13459
- const scoped = seatShipId ? rows.filter((r) => r.ship_id === seatShipId) : rows.filter((r) => !r.ship_id);
13460
- const unclaimed = seatShipId ? rows.filter((r) => !r.ship_id) : [];
13461
- for (const role of ["dev", "design", "qa", "triage"]) {
13462
- const searchTerm = role === "dev" ? "develop" : role;
13463
- let id = seatFor(scoped, searchTerm);
13464
- let seatAction = "matched";
13465
- if (!id && seatShipId && !shipRowId) {
13466
- const legacyId = seatFor(unclaimed, searchTerm);
13467
- if (legacyId) {
13468
- id = legacyId;
13469
- seatAction = "claimed";
13470
- }
13471
- } else if (!id && shipRowId) {
13472
- const legacyId = seatFor(unclaimed, searchTerm);
13473
- if (legacyId) {
13474
- await patch(o, `/data-models/${out.models.crew}/records/${legacyId}`, { ship_id: shipRowId });
13475
- id = legacyId;
13476
- seatAction = "claimed";
13477
- provisioning.push(`claimed existing Crew row for the ${role} seat`);
13478
- } else {
13479
- const created = await post2(o, `/data-models/${out.models.crew}/records`, {
13480
- name: `${PERSONA_NAME[role]} agent`,
13481
- ship_id: shipRowId,
13482
- ...agentIdFor(role) ? { agent_id: agentIdFor(role) } : {}
13483
- });
13484
- id = created.id;
13485
- seatAction = "created";
13486
- provisioning.push(`created Crew row for the ${role} seat`);
13487
- }
13488
- }
13489
- if (id) {
13490
- out.seats[role] = id;
13491
- const rowShip = rows.find((r) => r.id === id)?.ship_id ?? shipRowId ?? void 0;
13492
- (out.seatRows ??= []).push({ role, id, action: seatAction, ...rowShip ? { shipId: rowShip } : {} });
13493
- } else if (o.dryRun && o.ship && out.shipsModelId) {
13494
- (out.seatRows ??= []).push({ role, id: "", action: "would-create" });
13495
- } else problems.push(`no Crew row looking like the ${role} seat \u2014 create one, or omit that role`);
13496
- }
13497
- const pairId = seatFor(scoped, "pair") || (seatShipId ? seatFor(unclaimed, "pair") : void 0);
13498
- if (pairId) out.seats.pair = pairId;
13499
- if (shipRowId) {
13500
- const releaseId = seatFor(scoped, "release");
13501
- if (releaseId) out.releaseSeat = releaseId;
13502
- else {
13503
- const created = await post2(o, `/data-models/${out.models.crew}/records`, {
13504
- name: "Release agent",
13505
- ship_id: shipRowId
13506
- });
13507
- out.releaseSeat = created.id;
13508
- provisioning.push("created Crew row for the release seat");
13509
- }
13510
- }
13511
- const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
13512
- const holdRows = rows.filter((r) => !seatIds.has(r.id));
13513
- out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
13514
- out.crewModelId = out.models.crew;
13515
- const people = holdRows.filter((r) => !r.agent_id && (!r.ship_id || !!r.user_id));
13516
- const members = /* @__PURE__ */ new Map();
13528
+ }));
13529
+ return keys;
13530
+ }
13531
+ async function listLibraryTemplates(o) {
13532
+ return get(o, "/library-templates");
13533
+ }
13534
+ async function previewTemplateInstall(o, templateId, workspaceId) {
13535
+ return get(o, `/library-templates/${templateId}/install-preview?workspaceId=${workspaceId}`);
13536
+ }
13537
+ async function installTemplate(o, templateId, workspaceId, projectName) {
13538
+ return post2(o, `/library-templates/${templateId}/install`, {
13539
+ workspaceId,
13540
+ ...projectName ? { projectName } : {}
13541
+ });
13542
+ }
13543
+ async function listWorkspaces(o) {
13544
+ const res = await get(
13545
+ o,
13546
+ "/auth/my-workspaces"
13547
+ );
13548
+ return res.workspaces.map((w) => ({ slug: w.path ?? w.slug, name: w.name, role: w.role }));
13549
+ }
13550
+ var eq = (a, b) => (a ?? "").trim().toLowerCase() === b.trim().toLowerCase();
13551
+ function seatFor(rows, role) {
13552
+ const r = rows.find((x) => (x.name ?? "").toLowerCase().includes(role.toLowerCase()));
13553
+ return r?.id;
13554
+ }
13555
+ var REQUIRED_TABLES = ["Issues"];
13556
+ async function missingTables(o, projectId) {
13557
+ const models = await get(o, `/data-models?projectId=${projectId}`);
13558
+ return REQUIRED_TABLES.filter((t) => !models.some((m) => eq(m.name, t)));
13559
+ }
13560
+ async function discover(o) {
13561
+ const problems = [];
13562
+ const provisioning = [];
13563
+ const ws = await get(o, `/workspaces/${encodeURIComponent(o.workspace).replace(/%3A/gi, ":")}`);
13564
+ const out = {
13565
+ workspaceId: ws.id,
13566
+ workspaceSlug: ws.path ?? ws.slug,
13567
+ workspaceName: ws.name,
13568
+ models: {},
13569
+ seats: {},
13570
+ holds: [],
13571
+ candidates: [],
13572
+ problems,
13573
+ provisioning
13574
+ };
13575
+ try {
13576
+ const me = await get(o, `/auth/me?workspaceId=${ws.id}`);
13577
+ out.role = me.role;
13578
+ out.meEmail = me.email;
13579
+ out.meId = me.id;
13580
+ out.meIdentityId = me.identityId;
13581
+ out.meName = me.name;
13582
+ } catch {
13583
+ }
13584
+ if (o.project) {
13517
13585
  try {
13518
- const list = await get(
13586
+ const p = await get(
13519
13587
  o,
13520
- `/workspaces/${out.workspaceId}/members`
13588
+ `/projects/${encodeURIComponent(o.project)}?workspaceId=${ws.id}`
13521
13589
  );
13522
- for (const m of Array.isArray(list) ? list : []) {
13523
- const key = m.id ?? m.userId;
13524
- if (key) members.set(key, { identityId: m.identityId, email: m.email });
13525
- }
13590
+ out.projectId = p.id;
13591
+ out.projectSlug = p.slug;
13592
+ out.projectName = p.name;
13526
13593
  } catch {
13594
+ const projects = await get(o, `/projects?workspaceId=${ws.id}`);
13595
+ const hit = projects.find((p) => eq(p.name, o.project));
13596
+ if (hit) {
13597
+ out.projectId = hit.id;
13598
+ out.projectSlug = hit.slug;
13599
+ out.projectName = hit.name;
13600
+ } else problems.push(`no project named or slugged "${o.project}" \u2014 have: ${projects.map((p) => p.name).join(", ") || "(none)"}`);
13527
13601
  }
13528
- for (const h of out.holds) {
13529
- const e = h.user_id ? members.get(h.user_id)?.email : void 0;
13530
- if (e) h.memberEmail = e;
13531
- }
13532
- out.candidates = people.map((r) => ({
13533
- id: r.id,
13534
- name: r.name ?? "",
13535
- email: r.email,
13536
- user_id: r.user_id,
13537
- detail: r.email || (r.user_id ? members.get(r.user_id)?.email : void 0) || void 0
13538
- }));
13539
- const isMine = (r) => !!r.user_id && (r.user_id === out.meId || !!out.meIdentityId && members.get(r.user_id)?.identityId === out.meIdentityId);
13540
- const emailOf = (r) => [r.email, r.user_id ? members.get(r.user_id)?.email : void 0].filter((e) => !!e);
13541
- if (!out.meId) {
13542
- if (out.candidates.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
13543
- else {
13544
- const byEmail = out.meEmail ? people.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
13545
- if (byEmail.length === 1) out.operator = byEmail[0].id;
13546
- else if (out.candidates.length === 1) out.operator = out.candidates[0].id;
13547
- }
13602
+ } else {
13603
+ const projects = await get(o, `/projects?workspaceId=${ws.id}`);
13604
+ const issuesKeys = await resolveIssuesTemplateKeys(o);
13605
+ const defaultCandidate = projects.find((p) => !!p.sourceTemplateId && issuesKeys.has(p.sourceTemplateId)) ?? projects.find((p) => eq(p.slug ?? "", "issues"));
13606
+ if (defaultCandidate && (await missingTables(o, defaultCandidate.id)).length === 0) {
13607
+ out.projectId = defaultCandidate.id;
13608
+ out.projectSlug = defaultCandidate.slug;
13609
+ out.projectName = defaultCandidate.name;
13548
13610
  } else {
13549
- const candidates = people.filter((r) => !r.user_id || isMine(r));
13550
- const mine = people.find(isMine);
13551
- const byEmail = out.meEmail ? candidates.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
13552
- const unlinked = people.filter((r) => !r.user_id);
13553
- const only = candidates.length === 1 && unlinked.length === 1 ? candidates[0] : void 0;
13554
- const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
13555
- if (pick) {
13556
- out.operator = pick.id;
13557
- out.operatorAction = isMine(pick) ? "matched" : "linked";
13558
- if (!isMine(pick) && o.ship) await linkOperatorRow(o, out, pick);
13611
+ const options = await Promise.all(
13612
+ projects.map(async (p) => ({ id: p.id, name: p.name, slug: p.slug, missing: await missingTables(o, p.id) }))
13613
+ );
13614
+ const qualifying = options.filter((p) => p.missing.length === 0);
13615
+ if (qualifying.length === 1) {
13616
+ out.projectId = qualifying[0].id;
13617
+ out.projectSlug = qualifying[0].slug;
13618
+ out.projectName = qualifying[0].name;
13619
+ } else if (qualifying.length > 1) {
13620
+ out.projectOptions = qualifying;
13621
+ problems.push(
13622
+ `${qualifying.length} projects in this workspace all have the tables a route needs \u2014 name one: ${qualifying.map((p) => p.slug ?? p.name).join(", ")}`
13623
+ );
13559
13624
  } else {
13560
- out.operatorCompared = `signed in as ${out.meEmail ?? "unknown email"} (identity ${out.meIdentityId ?? out.meId}); ` + (people.length === 0 ? "no person rows in the Crew table" : people.map((r) => {
13561
- const m = r.user_id ? members.get(r.user_id) : void 0;
13562
- return `"${r.name || "unnamed row"}" <${r.email ?? "no email"}>` + (r.user_id ? ` linked to member ${m?.email ?? r.user_id}${m?.identityId ? ` (identity ${m.identityId})` : ""}` : " unlinked");
13563
- }).join("; "));
13564
- if (o.ship && (!o.interactive || candidates.length === 0)) await createOperatorRow(o, out);
13625
+ const isAdmin = out.role === "WORKSPACE_ADMIN" || out.role === "PLATFORM_ADMIN";
13626
+ out.offerTemplateInstall = isAdmin;
13627
+ problems.push(
13628
+ `no project in this workspace has the tables a route needs (${REQUIRED_TABLES.join(", ")}) \u2014 ` + (isAdmin ? "install an Issues-tracker template for this workspace (Library, in the app), then rerun." : "ask a workspace admin to set one up, then rerun.")
13629
+ );
13565
13630
  }
13566
13631
  }
13567
13632
  }
13568
- return out;
13569
- }
13570
- async function linkOperatorRow(o, found, row) {
13571
- const name = row.name || row.id;
13572
- if (!found.meId || !found.crewModelId) return;
13573
- if (o.dryRun) {
13574
- found.provisioning.push(`would link Crew row "${name}" to your account`);
13575
- return;
13576
- }
13577
- const body = { user_id: found.meId };
13578
- if (!row.email && found.meEmail) body.email = found.meEmail;
13579
- try {
13580
- await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
13581
- found.provisioning.push(`linked Crew row "${name}" to your account`);
13582
- } catch (e) {
13583
- if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
13584
- found.provisioning.push(
13585
- `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
13586
- );
13587
- }
13588
- }
13589
- async function createOperatorRow(o, found) {
13590
- if (!found.meId || !found.crewModelId) return;
13591
- const name = found.meName || found.meEmail || "Operator";
13592
- if (o.dryRun) {
13593
- found.provisioning.push(`would create Crew row "${name}" for you`);
13594
- return;
13633
+ if (!out.projectId) return out;
13634
+ const models = await get(o, `/data-models?projectId=${out.projectId}`);
13635
+ const find = (name) => models.find((m) => eq(m.name, name))?.id;
13636
+ for (const [key, name] of [["issues", "Issues"], ["comments", "Comments"], ["crew", "Crew"]]) {
13637
+ const id = find(name);
13638
+ if (id) out.models[key] = id;
13639
+ else problems.push(`no "${name}" table \u2014 have: ${models.map((m) => m.name).join(", ")}`);
13595
13640
  }
13596
- try {
13597
- const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
13598
- name,
13599
- ...found.meEmail ? { email: found.meEmail } : {},
13600
- user_id: found.meId
13601
- });
13602
- found.operator = created.id;
13603
- found.operatorAction = "created";
13604
- found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
13605
- found.candidates.push({ id: created.id, name, email: found.meEmail, user_id: found.meId, detail: found.meEmail });
13606
- found.provisioning.push(`created Crew row "${name}" for you`);
13607
- } catch (e) {
13608
- if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
13609
- found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
13641
+ if (out.models.issues) {
13642
+ try {
13643
+ const issuesModel = await get(o, `/data-models/${out.models.issues}`);
13644
+ const statusField = issuesModel.fields.find((f) => f.columnName === DEFAULT_CONTRACT.columns.status);
13645
+ const known = /* @__PURE__ */ new Set([
13646
+ ...DEFAULT_CONTRACT.statuses.open,
13647
+ ...DEFAULT_CONTRACT.statuses.resolved,
13648
+ DEFAULT_CONTRACT.statuses.draft
13649
+ ]);
13650
+ out.unrecognizedStatuses = (statusField?.fieldType?.choiceOptions ?? []).filter((opt) => !known.has(opt.value)).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)).map((opt) => ({ value: opt.value, label: opt.label ?? void 0, position: opt.position ?? void 0 }));
13651
+ } catch {
13652
+ }
13610
13653
  }
13611
- }
13612
- function renderConnection(d, route, dir, opts = {}) {
13613
- const apiKeyFileLine = opts.keychainBacked ? ` # apiKey: resolved automatically from the OS keychain (crew connect) \u2014 no apiKeyFile needed` : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
13614
- const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
13615
- apiKeyVar: ${opts.apiKeyVar}` : `
13616
- apiKeyVar: REPLACE_KEY_VAR`;
13617
- return ` - route: ${route}
13618
- enabled: false # arm it deliberately, once doctor is green
13619
- ${opts.area ? ` area: "${opts.area}"
13620
- ` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
13621
- # worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
13622
- # directory name (ISSUE-400). Only set this if every repo this route
13623
- # serves genuinely wants the SAME prefix, which is rare once an area has
13624
- # more than one repo.
13625
- # What a host must be to build this \u2014 unix, macos, linux, or windows \u2014
13626
- # belongs in each repo's own .crew.yaml, not here (see docs/REPO_SPEC.md).
13627
- baseUrl: ${opts.baseUrl ? `"${opts.baseUrl}"` : '"REPLACE \u2014 the same baseUrl as your other routes"'}
13628
- ${opts.hostPassengers ? " hostPassengers: true\n" : ""}${apiKeyFileLine}${apiKeyVarLine}
13629
- `;
13630
- }
13631
-
13632
- // src/cli.ts
13633
- import {
13634
- getSessionStore as getSessionStore3,
13635
- hostFromUrl as hostFromUrl4,
13636
- loginWithDeviceCode,
13637
- getOrCreateShipId,
13638
- DeviceLoginError
13639
- } from "@tablation/client";
13640
-
13641
- // src/seat-keys.ts
13642
- import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl3 } from "@tablation/client";
13643
- var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
13644
- function keyIsAdmin(route) {
13645
- const role = route.resolved?.role;
13646
- return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
13647
- }
13648
- var MEMBER_NO_ROW = "has no linked Agents row \u2014 a workspace admin's `crew agents sync` creates the persona rows; run it here afterwards to link this ship's seats";
13649
- var MEMBER_NO_KEY = 'no agent key for this seat \u2014 run `crew agents sync` (needs the "Mint agent keys" capability on your role)';
13650
- var FORBIDDEN_LINE = 'seat keys: not minted \u2014 your role does not grant "Mint agent keys"; ask a workspace admin to add it to your project role (or run sync with an admin key)';
13651
- function seatKeySlug(workspaceSlug, agentId) {
13652
- return `${workspaceSlug}#agent:${agentId}`;
13653
- }
13654
- function slugFor(route, agentId) {
13655
- const [workspaceSlug] = splitRoute(route.route);
13656
- return seatKeySlug(workspaceSlug, agentId);
13657
- }
13658
- function seatAgentId(route, role) {
13659
- return route.resolved?.agentPersonas?.[role]?.agentId;
13660
- }
13661
- async function resolveReleaseKey(route, getStore = getSessionStore2) {
13662
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
13663
- const agentId = route.resolved?.releaseAgentId;
13664
- if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
13665
- try {
13666
- const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
13667
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
13668
- return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
13669
- } catch {
13670
- return ship("OS keychain unavailable");
13654
+ if (out.models.issues) {
13655
+ try {
13656
+ await get(o, `/data-models/${out.models.issues}/events?limit=1`);
13657
+ } catch (e) {
13658
+ if (e instanceof ConnectHttpError && e.status === 404) problems.push(EVENT_LOG_MISSING);
13659
+ }
13671
13660
  }
13672
- }
13673
- async function resolveSeatKey(route, role, getStore = getSessionStore2) {
13674
- const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
13675
- if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
13676
- const agentId = seatAgentId(route, role);
13677
- if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
13661
+ out.areaModelId = find("Projects");
13662
+ out.reposModelId = find("Repos");
13663
+ out.shipsModelId = find("Ships");
13664
+ out.epicsModelId = find("Epics");
13665
+ out.locksModelId = find("Locks");
13678
13666
  try {
13679
- const session = await getStore().get(hostFromUrl3(route.baseUrl), slugFor(route, agentId));
13680
- if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
13681
- return ship(keyIsAdmin(route) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
13667
+ const views = await get(o, `/views?workspaceId=${ws.id}`);
13668
+ const candidates = views.filter((v) => v.recordLinkOnly === true && v.published === true && !!v.publicSlug && (v.pages ?? []).some((p) => (p.components ?? []).some((c) => c.dataModelId === out.models.issues)));
13669
+ if (candidates.length === 1) {
13670
+ out.recordLinkViewId = candidates[0].id;
13671
+ } else if (candidates.length === 0) {
13672
+ problems.push("no record-link-only view bound to Issues \u2014 attention events will carry no link");
13673
+ } else {
13674
+ problems.push(
13675
+ `${candidates.length} record-link-only views bound to Issues \u2014 set resolved.recordLinkViewId by hand`
13676
+ );
13677
+ }
13682
13678
  } catch {
13683
- return ship("OS keychain unavailable");
13684
13679
  }
13685
- }
13686
- function describeStoreError(e) {
13687
- const msg = e instanceof Error ? e.message : String(e);
13688
- if (/User interaction is not allowed/i.test(msg)) {
13689
- return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
13680
+ if (out.reposModelId) {
13681
+ const repos = await get(
13682
+ o,
13683
+ `/data-models/${out.reposModelId}/records?limit=200`
13684
+ );
13685
+ out.repoNames = Object.fromEntries(repos.map((r) => [r.id, r.name ?? ""]));
13686
+ const remotes = Object.fromEntries(
13687
+ repos.filter((r) => r.remote).map((r) => [r.id, r.remote])
13688
+ );
13689
+ if (Object.keys(remotes).length > 0) out.repoRemotes = remotes;
13690
13690
  }
13691
- return "keychain unavailable (could not write to the OS keychain)";
13692
- }
13693
- async function mintMissingSeatKeys(route, o) {
13694
- const out = [];
13695
- if (!route.resolved) return out;
13696
- const resolved = route.resolved;
13697
- const doFetch = o.fetchImpl ?? fetch;
13698
- const targets = [
13699
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
13700
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
13701
- ];
13702
- let store;
13703
- try {
13704
- store = (o.getStore ?? getSessionStore2)();
13705
- } catch (e) {
13706
- return targets.map(({ role }) => ({
13707
- role,
13708
- action: "failed",
13709
- detail: describeStoreError(e)
13710
- }));
13691
+ if (o.area) {
13692
+ if (!out.areaModelId) problems.push(`--area given but this project has no "Projects" table`);
13693
+ else {
13694
+ const areas = await get(o, `/data-models/${out.areaModelId}/records?limit=200`);
13695
+ const hit = areas.find((a) => eq(a.name, o.area));
13696
+ if (hit) {
13697
+ out.areaId = hit.id;
13698
+ out.areaName = hit.name;
13699
+ } else problems.push(`no area named "${o.area}" \u2014 have: ${areas.map((a) => a.name).join(", ")}`);
13700
+ }
13711
13701
  }
13712
- const host = hostFromUrl3(route.baseUrl);
13713
- const base = route.baseUrl.replace(/\/+$/, "");
13714
- if (!o.dryRun) {
13715
- const probeSlug = seatKeySlug(splitRoute(route.route)[0], "_probe");
13702
+ let shipRowId;
13703
+ if (out.shipsModelId && o.ship && !o.dryRun) {
13704
+ const shipRows = await get(
13705
+ o,
13706
+ `/data-models/${out.shipsModelId}/records?limit=200`
13707
+ );
13708
+ const hostPassengers = o.ship.hostPassengers ?? false;
13709
+ const sshPublicKey = o.ship.sshPublicKey;
13710
+ let shipColumns = /* @__PURE__ */ new Set();
13716
13711
  try {
13717
- await store.set(host, probeSlug, {
13718
- apiKey: "probe",
13719
- shipId: "",
13720
- workspaceId: resolved.workspaceId,
13721
- identityId: "_probe",
13722
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
13723
- });
13724
- await store.clear(host, probeSlug);
13725
- } catch (e) {
13726
- const detail = `${describeStoreError(e)}; no keys minted`;
13727
- return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
13712
+ const shipsModel = await get(o, `/data-models/${out.shipsModelId}`);
13713
+ shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
13714
+ } catch {
13728
13715
  }
13729
- }
13730
- for (const { role, agentId } of targets) {
13731
- if (!agentId) {
13732
- out.push({ role, action: "no_agent" });
13733
- continue;
13716
+ let ownerId;
13717
+ if (out.meId) {
13718
+ if (shipColumns.has("owner_id")) ownerId = out.meId;
13719
+ else provisioning.push("could not record the ship owner \u2014 the Ships table has no owner_id column yet (update the Issues template)");
13734
13720
  }
13735
- const slug = slugFor(route, agentId);
13736
- try {
13737
- if ((await store.get(host, slug))?.apiKey) {
13738
- out.push({ role, action: "already_stored" });
13739
- continue;
13740
- }
13741
- if (o.dryRun) {
13742
- out.push({ role, action: "minted", detail: "dry run" });
13743
- continue;
13721
+ const machineId = shipColumns.has("machine_id") ? o.ship.machineId : void 0;
13722
+ let mine;
13723
+ let matchedBy = "name";
13724
+ let writeMachineId = false;
13725
+ const byName = shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim());
13726
+ if (machineId) {
13727
+ const byId = shipRows.filter((r) => (r.machine_id ?? "") === machineId);
13728
+ if (byId.length > 0) {
13729
+ mine = byId;
13730
+ matchedBy = "machine_id";
13731
+ } else {
13732
+ mine = byName.filter((r) => !r.machine_id);
13733
+ writeMachineId = true;
13744
13734
  }
13745
- const res = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
13746
- method: "POST",
13747
- headers: {
13748
- Authorization: `Bearer ${resolveApiKey(route)}`,
13749
- "Content-Type": "application/json",
13750
- "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
13751
- },
13752
- body: JSON.stringify({ name: `crew ${o.shipName ?? "ship"} ${role}` })
13753
- });
13754
- if (res.status === 403) {
13755
- out.push({ role, action: "forbidden" });
13756
- break;
13735
+ } else {
13736
+ mine = byName;
13737
+ }
13738
+ if (mine.length === 1) {
13739
+ shipRowId = mine[0].id;
13740
+ out.shipRow = { id: shipRowId, name: o.ship.name, action: "matched", matchedBy };
13741
+ const patchBody = {};
13742
+ if (matchedBy === "machine_id") {
13743
+ const rowName = (mine[0].name ?? "").trim();
13744
+ const route = `${out.workspaceSlug}/${out.projectSlug ?? out.projectId}`;
13745
+ const plan = planNameSync(o.ship.name.trim(), rowName, o.ship.lastSyncedName?.(route));
13746
+ if (plan.action === "push") {
13747
+ patchBody.name = o.ship.name.trim();
13748
+ provisioning.push(`renamed Ships row "${rowName}" to "${o.ship.name.trim()}" (crew.yaml changed)`);
13749
+ } else if (plan.action === "adopt" || plan.action === "row-wins") {
13750
+ out.shipRow.name = rowName;
13751
+ out.shipNameAdopted = rowName;
13752
+ provisioning.push(`matched Ships row "${rowName}" by machine id \u2014 this host's name becomes "${rowName}"${plan.action === "row-wins" ? " (crew.yaml also changed; the Ships row wins)" : ""}`);
13753
+ }
13754
+ patchBody.last_seen = (/* @__PURE__ */ new Date()).toISOString();
13757
13755
  }
13758
- if (!res.ok) {
13759
- out.push({ role, action: "failed", detail: `${res.status} ${res.statusText}` });
13760
- continue;
13756
+ if (writeMachineId) patchBody.machine_id = machineId;
13757
+ if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
13758
+ if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
13759
+ patchBody.ssh_public_key = sshPublicKey;
13761
13760
  }
13762
- const body = await res.json();
13763
- if (!body.key) {
13764
- out.push({ role, action: "failed", detail: "response carried no key" });
13765
- continue;
13761
+ if (ownerId !== void 0 && (mine[0].owner_id ?? "") !== ownerId) patchBody.owner_id = ownerId;
13762
+ if (Object.keys(patchBody).length > 0) {
13763
+ await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
13764
+ if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
13765
+ if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
13766
+ if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
13766
13767
  }
13768
+ } else if (mine.length === 0) {
13769
+ const created = await post2(o, `/data-models/${out.shipsModelId}/records`, {
13770
+ name: o.ship.name,
13771
+ ...o.ship.platform ? { platform: o.ship.platform } : {},
13772
+ host_passengers: hostPassengers,
13773
+ ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
13774
+ ...ownerId !== void 0 ? { owner_id: ownerId } : {},
13775
+ ...writeMachineId ? { machine_id: machineId } : {}
13776
+ });
13777
+ shipRowId = created.id;
13778
+ out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
13779
+ provisioning.push(`created Ships row "${o.ship.name}"`);
13780
+ } else {
13781
+ problems.push(
13782
+ matchedBy === "machine_id" ? `${mine.length} Ships rows carry this machine's id \u2014 which one is this machine?` : `${mine.length} Ships rows named "${o.ship.name}" \u2014 which one is this machine?`
13783
+ );
13784
+ }
13785
+ }
13786
+ let agentIdFor = () => void 0;
13787
+ if (shipRowId) {
13788
+ try {
13789
+ const agentsModel = await get(o, `/data-models/agents?workspaceId=${ws.id}`);
13790
+ const agentRows = await get(
13791
+ o,
13792
+ `/data-models/${agentsModel.id}/records?limit=200`
13793
+ );
13794
+ agentIdFor = (role) => agentRows.find((r) => r.name === PERSONA_NAME[role])?.id;
13795
+ } catch {
13796
+ }
13797
+ }
13798
+ if (out.models.crew) {
13799
+ const rows = await get(
13800
+ o,
13801
+ `/data-models/${out.models.crew}/records?limit=200`
13802
+ );
13803
+ let seatShipId = shipRowId;
13804
+ if (!seatShipId && o.dryRun && o.ship && out.shipsModelId) {
13767
13805
  try {
13768
- const session = {
13769
- apiKey: body.key,
13770
- shipId: o.shipName ?? "",
13771
- workspaceId: resolved.workspaceId,
13772
- identityId: agentId,
13773
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13774
- ...body.id ? { keyId: body.id } : {}
13775
- };
13776
- await store.set(host, slug, session);
13777
- } catch (e) {
13778
- let revoked = false;
13779
- if (body.id) {
13780
- try {
13781
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
13782
- method: "DELETE",
13783
- headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
13784
- });
13785
- revoked = del2.ok;
13786
- } catch {
13787
- revoked = false;
13788
- }
13806
+ const shipRows = await get(
13807
+ o,
13808
+ `/data-models/${out.shipsModelId}/records?limit=200`
13809
+ );
13810
+ const byId = o.ship.machineId ? shipRows.filter((r) => r.machine_id === o.ship.machineId) : [];
13811
+ const mineRows = byId.length > 0 ? byId : shipRows.filter((r) => (r.name ?? "").trim() === o.ship.name.trim() && !r.machine_id);
13812
+ if (mineRows.length === 1) seatShipId = mineRows[0].id;
13813
+ } catch {
13814
+ }
13815
+ }
13816
+ const scoped = seatShipId ? rows.filter((r) => r.ship_id === seatShipId) : rows.filter((r) => !r.ship_id);
13817
+ const unclaimed = seatShipId ? rows.filter((r) => !r.ship_id) : [];
13818
+ for (const role of ["dev", "design", "qa", "triage"]) {
13819
+ const searchTerm = role === "dev" ? "develop" : role;
13820
+ let id = seatFor(scoped, searchTerm);
13821
+ let seatAction = "matched";
13822
+ if (!id && seatShipId && !shipRowId) {
13823
+ const legacyId = seatFor(unclaimed, searchTerm);
13824
+ if (legacyId) {
13825
+ id = legacyId;
13826
+ seatAction = "claimed";
13827
+ }
13828
+ } else if (!id && shipRowId) {
13829
+ const legacyId = seatFor(unclaimed, searchTerm);
13830
+ if (legacyId) {
13831
+ await patch(o, `/data-models/${out.models.crew}/records/${legacyId}`, { ship_id: shipRowId });
13832
+ id = legacyId;
13833
+ seatAction = "claimed";
13834
+ provisioning.push(`claimed existing Crew row for the ${role} seat`);
13835
+ } else {
13836
+ const created = await post2(o, `/data-models/${out.models.crew}/records`, {
13837
+ name: `${PERSONA_NAME[role]} agent`,
13838
+ ship_id: shipRowId,
13839
+ ...agentIdFor(role) ? { agent_id: agentIdFor(role) } : {}
13840
+ });
13841
+ id = created.id;
13842
+ seatAction = "created";
13843
+ provisioning.push(`created Crew row for the ${role} seat`);
13789
13844
  }
13790
- out.push({
13791
- role,
13792
- action: "failed",
13793
- detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
13794
- });
13795
- continue;
13796
13845
  }
13797
- out.push({ role, action: "minted" });
13798
- } catch (e) {
13799
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
13846
+ if (id) {
13847
+ out.seats[role] = id;
13848
+ const rowShip = rows.find((r) => r.id === id)?.ship_id ?? shipRowId ?? void 0;
13849
+ (out.seatRows ??= []).push({ role, id, action: seatAction, ...rowShip ? { shipId: rowShip } : {} });
13850
+ } else if (o.dryRun && o.ship && out.shipsModelId) {
13851
+ (out.seatRows ??= []).push({ role, id: "", action: "would-create" });
13852
+ } else problems.push(`no Crew row looking like the ${role} seat \u2014 create one, or omit that role`);
13800
13853
  }
13801
- }
13802
- return out;
13803
- }
13804
- function describeMintOutcome(o) {
13805
- switch (o.action) {
13806
- case "minted":
13807
- return `${o.role}: agent key minted${o.detail ? ` (${o.detail})` : ""}`;
13808
- case "already_stored":
13809
- return `${o.role}: agent key already stored`;
13810
- case "no_agent":
13811
- return `${o.role}: no linked Agents row \u2014 writes as the ship until \`crew agents sync\` links one`;
13812
- case "forbidden":
13813
- return FORBIDDEN_LINE;
13814
- case "failed":
13815
- return `${o.role}: could not mint an agent key (${o.detail ?? "unknown"}) \u2014 writes as the ship`;
13816
- }
13817
- }
13818
- async function revokeSeatKeys(route, o) {
13819
- const out = [];
13820
- if (!route.resolved) return out;
13821
- const resolved = route.resolved;
13822
- const doFetch = o.fetchImpl ?? fetch;
13823
- const targets = [
13824
- ...SEAT_KEY_ROLES.filter((r) => resolved.seats[r]).map((role) => ({ role, agentId: seatAgentId(route, role) })),
13825
- ...resolved.releaseSeat ? [{ role: "release", agentId: resolved.releaseAgentId }] : []
13826
- ];
13827
- let store;
13828
- try {
13829
- store = (o.getStore ?? getSessionStore2)();
13830
- } catch (e) {
13831
- return targets.map(({ role }) => ({ role, action: "failed", detail: describeStoreError(e) }));
13832
- }
13833
- const host = hostFromUrl3(route.baseUrl);
13834
- const base = route.baseUrl.replace(/\/+$/, "");
13835
- for (const { role, agentId } of targets) {
13836
- if (!agentId) {
13837
- out.push({ role, action: "no_agent" });
13838
- continue;
13854
+ const pairId = seatFor(scoped, "pair") || (seatShipId ? seatFor(unclaimed, "pair") : void 0);
13855
+ if (pairId) out.seats.pair = pairId;
13856
+ if (shipRowId) {
13857
+ const releaseId = seatFor(scoped, "release");
13858
+ if (releaseId) out.releaseSeat = releaseId;
13859
+ else {
13860
+ const created = await post2(o, `/data-models/${out.models.crew}/records`, {
13861
+ name: "Release agent",
13862
+ ship_id: shipRowId
13863
+ });
13864
+ out.releaseSeat = created.id;
13865
+ provisioning.push("created Crew row for the release seat");
13866
+ }
13839
13867
  }
13840
- const slug = slugFor(route, agentId);
13868
+ const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
13869
+ const holdRows = rows.filter((r) => !seatIds.has(r.id));
13870
+ out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
13871
+ out.crewModelId = out.models.crew;
13872
+ const people = holdRows.filter((r) => !r.agent_id && (!r.ship_id || !!r.user_id));
13873
+ const members = /* @__PURE__ */ new Map();
13841
13874
  try {
13842
- const session = await store.get(host, slug);
13843
- if (!session?.apiKey) {
13844
- out.push({ role, action: "not_stored" });
13845
- continue;
13846
- }
13847
- if (!session.keyId) {
13848
- out.push({ role, action: "failed", detail: "no key id was recorded when this key was minted \u2014 revoke it from the seat's Agents row" });
13849
- continue;
13875
+ const list = await get(
13876
+ o,
13877
+ `/workspaces/${out.workspaceId}/members`
13878
+ );
13879
+ for (const m of Array.isArray(list) ? list : []) {
13880
+ const key = m.id ?? m.userId;
13881
+ if (key) members.set(key, { identityId: m.identityId, email: m.email });
13850
13882
  }
13851
- if (o.dryRun) {
13852
- out.push({ role, action: "revoked", detail: "dry run" });
13853
- continue;
13883
+ } catch {
13884
+ }
13885
+ for (const h of out.holds) {
13886
+ const e = h.user_id ? members.get(h.user_id)?.email : void 0;
13887
+ if (e) h.memberEmail = e;
13888
+ }
13889
+ out.candidates = people.map((r) => ({
13890
+ id: r.id,
13891
+ name: r.name ?? "",
13892
+ email: r.email,
13893
+ user_id: r.user_id,
13894
+ detail: r.email || (r.user_id ? members.get(r.user_id)?.email : void 0) || void 0
13895
+ }));
13896
+ const isMine = (r) => !!r.user_id && (r.user_id === out.meId || !!out.meIdentityId && members.get(r.user_id)?.identityId === out.meIdentityId);
13897
+ const emailOf = (r) => [r.email, r.user_id ? members.get(r.user_id)?.email : void 0].filter((e) => !!e);
13898
+ if (!out.meId) {
13899
+ if (out.candidates.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
13900
+ else {
13901
+ const byEmail = out.meEmail ? people.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
13902
+ if (byEmail.length === 1) out.operator = byEmail[0].id;
13903
+ else if (out.candidates.length === 1) out.operator = out.candidates[0].id;
13854
13904
  }
13855
- const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${session.keyId}`, {
13856
- method: "DELETE",
13857
- headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
13858
- });
13859
- if (!del2.ok && del2.status !== 404) {
13860
- out.push({ role, action: "failed", detail: `${del2.status} ${del2.statusText}` });
13861
- continue;
13905
+ } else {
13906
+ const candidates = people.filter((r) => !r.user_id || isMine(r));
13907
+ const mine = people.find(isMine);
13908
+ const byEmail = out.meEmail ? candidates.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
13909
+ const unlinked = people.filter((r) => !r.user_id);
13910
+ const only = candidates.length === 1 && unlinked.length === 1 ? candidates[0] : void 0;
13911
+ const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
13912
+ if (pick) {
13913
+ out.operator = pick.id;
13914
+ out.operatorAction = isMine(pick) ? "matched" : "linked";
13915
+ if (!isMine(pick) && o.ship) await linkOperatorRow(o, out, pick);
13916
+ } else {
13917
+ out.operatorCompared = `signed in as ${out.meEmail ?? "unknown email"} (identity ${out.meIdentityId ?? out.meId}); ` + (people.length === 0 ? "no person rows in the Crew table" : people.map((r) => {
13918
+ const m = r.user_id ? members.get(r.user_id) : void 0;
13919
+ return `"${r.name || "unnamed row"}" <${r.email ?? "no email"}>` + (r.user_id ? ` linked to member ${m?.email ?? r.user_id}${m?.identityId ? ` (identity ${m.identityId})` : ""}` : " unlinked");
13920
+ }).join("; "));
13921
+ if (o.ship && (!o.interactive || candidates.length === 0)) await createOperatorRow(o, out);
13862
13922
  }
13863
- await store.clear(host, slug);
13864
- out.push({ role, action: "revoked", detail: del2.ok ? void 0 : "server already had no such key" });
13865
- } catch (e) {
13866
- out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
13867
13923
  }
13868
13924
  }
13869
13925
  return out;
13870
13926
  }
13871
- function describeRevokeOutcome(o) {
13872
- switch (o.action) {
13873
- case "revoked":
13874
- return `${o.role}: agent key revoked and cleared from the keychain${o.detail ? ` (${o.detail})` : ""}`;
13875
- case "not_stored":
13876
- return `${o.role}: no agent key stored on this ship`;
13877
- case "no_agent":
13878
- return `${o.role}: no linked Agents row`;
13879
- case "failed":
13880
- return `${o.role}: could not revoke the agent key (${o.detail ?? "unknown"}) \u2014 left in the keychain`;
13927
+ async function linkOperatorRow(o, found, row) {
13928
+ const name = row.name || row.id;
13929
+ if (!found.meId || !found.crewModelId) return;
13930
+ if (o.dryRun) {
13931
+ found.provisioning.push(`would link Crew row "${name}" to your account`);
13932
+ return;
13933
+ }
13934
+ const body = { user_id: found.meId };
13935
+ if (!row.email && found.meEmail) body.email = found.meEmail;
13936
+ try {
13937
+ await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
13938
+ found.provisioning.push(`linked Crew row "${name}" to your account`);
13939
+ } catch (e) {
13940
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
13941
+ found.provisioning.push(
13942
+ `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
13943
+ );
13881
13944
  }
13882
13945
  }
13883
- async function canMintAgentKeys(route, o = {}) {
13884
- const resolved = route.resolved;
13885
- if (!resolved) return void 0;
13886
- const agentId = seatAgentId(route, "dev") ?? SEAT_KEY_ROLES.map((r) => seatAgentId(route, r)).find(Boolean);
13887
- if (!agentId) return void 0;
13946
+ async function createOperatorRow(o, found) {
13947
+ if (!found.meId || !found.crewModelId) return;
13948
+ const name = found.meName || found.meEmail || "Operator";
13949
+ if (o.dryRun) {
13950
+ found.provisioning.push(`would create Crew row "${name}" for you`);
13951
+ return;
13952
+ }
13888
13953
  try {
13889
- const res = await (o.fetchImpl ?? fetch)(`${route.baseUrl.replace(/\/+$/, "")}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys`, {
13890
- headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
13954
+ const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
13955
+ name,
13956
+ ...found.meEmail ? { email: found.meEmail } : {},
13957
+ user_id: found.meId
13891
13958
  });
13892
- if (res.ok) return true;
13893
- return res.status === 403 ? false : void 0;
13894
- } catch {
13895
- return void 0;
13959
+ found.operator = created.id;
13960
+ found.operatorAction = "created";
13961
+ found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
13962
+ found.candidates.push({ id: created.id, name, email: found.meEmail, user_id: found.meId, detail: found.meEmail });
13963
+ found.provisioning.push(`created Crew row "${name}" for you`);
13964
+ } catch (e) {
13965
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
13966
+ found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
13896
13967
  }
13897
13968
  }
13969
+ function renderConnection(d, route, dir, opts = {}) {
13970
+ const apiKeyFileLine = opts.keychainBacked ? ` # apiKey: resolved automatically from the OS keychain (crew connect) \u2014 no apiKeyFile needed` : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
13971
+ const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
13972
+ apiKeyVar: ${opts.apiKeyVar}` : `
13973
+ apiKeyVar: REPLACE_KEY_VAR`;
13974
+ return ` - route: ${route}
13975
+ enabled: false # arm it deliberately, once doctor is green
13976
+ ${opts.area ? ` area: "${opts.area}"
13977
+ ` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
13978
+ # worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
13979
+ # directory name (ISSUE-400). Only set this if every repo this route
13980
+ # serves genuinely wants the SAME prefix, which is rare once an area has
13981
+ # more than one repo.
13982
+ # What a host must be to build this \u2014 unix, macos, linux, or windows \u2014
13983
+ # belongs in each repo's own .crew.yaml, not here (see docs/REPO_SPEC.md).
13984
+ baseUrl: ${opts.baseUrl ? `"${opts.baseUrl}"` : '"REPLACE \u2014 the same baseUrl as your other routes"'}
13985
+ ${opts.hostPassengers ? " hostPassengers: true\n" : ""}${apiKeyFileLine}${apiKeyVarLine}
13986
+ `;
13987
+ }
13988
+
13989
+ // src/cli.ts
13990
+ import {
13991
+ getSessionStore as getSessionStore3,
13992
+ hostFromUrl as hostFromUrl4,
13993
+ loginWithDeviceCode,
13994
+ getOrCreateShipId,
13995
+ DeviceLoginError
13996
+ } from "@tablation/client";
13898
13997
 
13899
13998
  // src/capability-check.ts
13900
13999
  var WRITE_LEVELS = /* @__PURE__ */ new Set(["WRITE", "DELETE"]);
@@ -16228,6 +16327,7 @@ async function runOnePassUnguarded(o) {
16228
16327
  }
16229
16328
  const decision = await decide(ctx);
16230
16329
  o.state.attention(o.route.route).persist(decision.attention);
16330
+ o.state.unroutable(o.route.route).persist(decision.unroutable);
16231
16331
  if (decision.sweep.length) {
16232
16332
  const tracker = new Tracker(o.route, o.ship);
16233
16333
  const seat = o.route.resolved?.seats.qa ?? o.route.resolved?.seats.dev ?? "";
@@ -17392,6 +17492,7 @@ ${changed2.map((c) => ` - ${c}
17392
17492
  let seen = precheckTickets.get(c.route);
17393
17493
  if (!seen) precheckTickets.set(c.route, seen = tracker.openTickets());
17394
17494
  const { byRepo: preByRepo, unplaceable: preUnplaceable } = ticketsByRepo(c, await seen);
17495
+ clearShipAttention(state, SEAT_FORBIDDEN_KEY);
17395
17496
  const preModes = { modes: await tracker.projectWorkModes(), agentAssignees: /* @__PURE__ */ new Set() };
17396
17497
  const pre = withoutManualVerified(
17397
17498
  preByRepo.get(target.name) ?? [],
@@ -17699,6 +17800,12 @@ ${changed2.map((c) => ` - ${c}
17699
17800
  await sweepClosedTickets(c, target, repo.branch, tracker, dryRun, remit);
17700
17801
  return summary;
17701
17802
  } catch (e) {
17803
+ if (isForbiddenError(e)) {
17804
+ await raiseShipAttention({ state, emit: remit, route: c, ship: cfg.ship }, seatForbiddenItem());
17805
+ const line = seatForbiddenRepoLine(scope);
17806
+ remit.warn(line, { step: "release" });
17807
+ return { scope, tests: "skipped", outcome: "error", detail: line };
17808
+ }
17702
17809
  remit.warn(`release phase failed: ${e.message}`, { step: "release" });
17703
17810
  return { scope, tests: "skipped", outcome: "error", detail: e.message };
17704
17811
  } finally {
@@ -17979,7 +18086,10 @@ ${changed2.map((c) => ` - ${c}
17979
18086
  break;
17980
18087
  }
17981
18088
  const decision = await decideCycle({ route, ship: cfg.ship, state, emit });
17982
- if (!dryRun) state.attention(route.route).persist(decision.attention);
18089
+ if (!dryRun) {
18090
+ state.attention(route.route).persist(decision.attention);
18091
+ state.unroutable(route.route).persist(decision.unroutable);
18092
+ }
17983
18093
  if (decision.sweep.length) {
17984
18094
  if (dryRun) {
17985
18095
  for (const s of decision.sweep) {
@@ -18439,6 +18549,15 @@ ${changed2.map((c) => ` - ${c}
18439
18549
  process.stdout.write(`${describeMintOutcome(o)}
18440
18550
  `);
18441
18551
  }
18552
+ if (!dryRun) {
18553
+ for (const p of await probeSeatReads(target, { userAgent: cfg.ship.userAgent })) {
18554
+ if (p.state === "forbidden") {
18555
+ process.stdout.write(`${describeSeatForbidden(p.role)}
18556
+ `);
18557
+ process.exitCode = 1;
18558
+ }
18559
+ }
18560
+ }
18442
18561
  }
18443
18562
  if (run2.diverged) process.exitCode = 1;
18444
18563
  break;
@@ -19379,6 +19498,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
19379
19498
  mintOutcomes = await mintMissingSeatKeys({ ...existingRoute, resolved: { ...existingRoute.resolved, seats: found.seats } }, { userAgent: cfg.ship.userAgent, shipName: cfg.ship.name });
19380
19499
  for (const o of mintOutcomes) process.stderr.write(`${describeMintOutcome(o)}
19381
19500
  `);
19501
+ for (const p of await probeSeatReads({ ...existingRoute, resolved: { ...existingRoute.resolved, seats: found.seats } }, { userAgent: cfg.ship.userAgent })) {
19502
+ if (p.state === "forbidden") process.stderr.write(`${describeSeatForbidden(p.role)}
19503
+ `);
19504
+ }
19382
19505
  } catch (e) {
19383
19506
  process.stderr.write(`seat keys: skipped (${e.message})
19384
19507
  `);
@@ -19543,6 +19666,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
19543
19666
  const minted = await mintMissingSeatKeys(syncRoute, { userAgent: cfg.ship.userAgent, shipName: cfg.ship.name });
19544
19667
  for (const m of minted) process.stderr.write(` ${describeMintOutcome(m)}
19545
19668
  `);
19669
+ for (const p of await probeSeatReads(syncRoute, { userAgent: cfg.ship.userAgent })) {
19670
+ if (p.state === "forbidden") process.stderr.write(` ${describeSeatForbidden(p.role)}
19671
+ `);
19672
+ }
19546
19673
  if (run2.readOnly) {
19547
19674
  const missing = run2.personaResult.outcomes.some((p) => p.action === "missing");
19548
19675
  process.stderr.write(missing ? " A workspace admin has to open the Crew app or run `crew agents sync` once before this ship's seats can run.\n" : " Seats are linked to the workspace's existing personas.\n");
@@ -20495,6 +20622,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20495
20622
  // Additive (CREW-1496): each project and its work mode. `version` stays 1.
20496
20623
  projects: projects.map((p) => ({ id: p.id, name: p.name, workMode: p.mode })),
20497
20624
  dir: route.dir,
20625
+ // Additive (CREW-2548): open tickets with no repository, as of the last cycle.
20626
+ unroutable: state.unroutable(route.route).previous().length,
20498
20627
  enabled: route.enabled,
20499
20628
  area: route.area ?? null,
20500
20629
  // Additive (CREW-1511): the toolchain this route's sessions run on.
@@ -20662,6 +20791,13 @@ watermark: ${state.watermark()}
20662
20791
  passed += f.passed;
20663
20792
  }
20664
20793
  }
20794
+ if (!flag("offline")) {
20795
+ for (const p of await probeSeatReads(route, { userAgent: cfg.ship.userAgent })) {
20796
+ if (p.state === "forbidden") {
20797
+ problems.push({ severity: "blocker", kind: "seat_cannot_read_issues", route: route.route, message: `${p.role} agent key cannot read Issues (403)`, remedy: SEAT_NO_READ_REMEDY });
20798
+ } else if (p.state === "ok") passed++;
20799
+ }
20800
+ }
20665
20801
  problems.push(...attentionProblems(openShipAttention(state), problems));
20666
20802
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
20667
20803
  process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
@@ -20801,6 +20937,19 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
20801
20937
  `
20802
20938
  );
20803
20939
  }
20940
+ if (!flag("offline")) {
20941
+ for (const p of await probeSeatReads(roleRoute, { userAgent: cfg.ship.userAgent })) {
20942
+ if (p.state === "forbidden") {
20943
+ process.stdout.write(`${describeSeatForbidden(p.role)}
20944
+ `);
20945
+ process.exitCode = 1;
20946
+ } else {
20947
+ process.stdout.write(p.state === "ok" ? `seat read: ${p.role} can read Issues
20948
+ ` : `seat read: ${p.role} could not be checked (${p.detail})
20949
+ `);
20950
+ }
20951
+ }
20952
+ }
20804
20953
  const [ships, crewRows] = await Promise.all([tracker.shipRows(), tracker.crewRows()]);
20805
20954
  if (ships.length === 0) {
20806
20955
  process.stdout.write("ship record: this workspace has no Ships table (fine)\n");