@tablation/crew 0.75.0 → 0.75.1

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