@tablation/crew 0.50.2 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +222 -51
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2288,6 +2288,7 @@ function parseResolved(raw, m, where) {
2288
2288
  contract: raw.contract ?? void 0,
2289
2289
  ...typeof raw.releaseSeat === "string" && raw.releaseSeat ? { releaseSeat: raw.releaseSeat } : {},
2290
2290
  ...typeof raw.releaseAgentId === "string" && raw.releaseAgentId ? { releaseAgentId: raw.releaseAgentId } : {},
2291
+ ...typeof raw.role === "string" && raw.role ? { role: raw.role } : {},
2291
2292
  ...Object.keys(agentPersonas).length > 0 ? { agentPersonas } : {},
2292
2293
  ...Object.keys(agentSkills).length > 0 ? { agentSkills } : {}
2293
2294
  };
@@ -7724,6 +7725,25 @@ async function fetchLatestHistoryId(route, agentsModelId, recordId, userAgent) {
7724
7725
  return void 0;
7725
7726
  }
7726
7727
  }
7728
+ function isAdminRole(role) {
7729
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
7730
+ }
7731
+ async function fetchKeyRole(route, userAgent) {
7732
+ if (!route.resolved) return void 0;
7733
+ try {
7734
+ const res = await fetch(`${route.baseUrl.replace(/\/+$/, "")}/api/auth/me?workspaceId=${route.resolved.workspaceId}`, {
7735
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
7736
+ });
7737
+ if (!res.ok) return void 0;
7738
+ const me = await res.json();
7739
+ return typeof me.role === "string" ? me.role : void 0;
7740
+ } catch {
7741
+ return void 0;
7742
+ }
7743
+ }
7744
+ function isForbidden(e) {
7745
+ return e instanceof TablationApiError && e.status === 403;
7746
+ }
7727
7747
  function promptSourceFields(model, prompt) {
7728
7748
  return model.fields?.some((f) => f.columnName === "prompt_baseline") ? { prompt_source: "crew", prompt_baseline: prompt } : {};
7729
7749
  }
@@ -7767,6 +7787,16 @@ async function syncPersonas(route, opts) {
7767
7787
  const name = PERSONA_NAME[role];
7768
7788
  const defaultPrompt = personaDefaultPrompt(route.promptsDir, role);
7769
7789
  const row = rows.find((r) => r.name === name);
7790
+ if (opts.readOnly) {
7791
+ if (!row) {
7792
+ outcomes.push({ role, action: "missing", agentId: "" });
7793
+ continue;
7794
+ }
7795
+ const prior = cache[role]?.agentId === row.id ? cache[role] : void 0;
7796
+ agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: prior?.lastSyncedUpdatedAt ?? row.updated_at, ...prior?.historyId ? { historyId: prior.historyId } : {} };
7797
+ outcomes.push({ role, action: "adopted", agentId: row.id });
7798
+ continue;
7799
+ }
7770
7800
  if (!row) {
7771
7801
  if (opts.dryRun) {
7772
7802
  outcomes.push({ role, action: "created", agentId: "(dry run)" });
@@ -7868,35 +7898,70 @@ async function syncPersonas(route, opts) {
7868
7898
  throw e;
7869
7899
  }
7870
7900
  }
7871
- const crewLinks = opts.dryRun ? [] : await linkCrewSeats(route, client2, agentsModel.id, agentPersonas, opts.userAgent);
7872
- const releaseAgentId = opts.dryRun ? route.resolved.releaseAgentId : await linkReleaseSeat(route, client2, agentsModel, opts.userAgent);
7873
- return { outcomes, agentPersonas, crewLinks, ...releaseAgentId ? { releaseAgentId } : {} };
7901
+ const crewLinks = opts.dryRun ? [] : await linkCrewSeats(route, client2, agentsModel.id, agentPersonas, opts.userAgent, opts.readOnly === true);
7902
+ const release = opts.dryRun ? { agentId: route.resolved.releaseAgentId } : await linkReleaseSeat(route, client2, agentsModel, opts.userAgent, opts.readOnly === true);
7903
+ return {
7904
+ outcomes,
7905
+ agentPersonas,
7906
+ crewLinks,
7907
+ ...release.agentId ? { releaseAgentId: release.agentId } : {},
7908
+ ...release.note ? { releaseNote: release.note } : {},
7909
+ ...opts.readOnly ? { readOnly: true } : {}
7910
+ };
7874
7911
  }
7875
- async function linkReleaseSeat(route, client2, agentsModel, userAgent) {
7912
+ async function linkReleaseSeat(route, client2, agentsModel, userAgent, readOnly) {
7876
7913
  const seatId = route.resolved.releaseSeat;
7877
- if (!seatId) return void 0;
7914
+ if (!seatId) return {};
7878
7915
  const rows = await client2.records.list(agentsModel.id, { limit: 200 });
7879
- const agentId = rows.find((r) => r.name === RELEASE_AGENT_NAME)?.id ?? (await client2.records.create(agentsModel.id, {
7880
- name: RELEASE_AGENT_NAME,
7881
- prompt: RELEASE_AGENT_PROMPT,
7882
- ...promptSourceFields(agentsModel, RELEASE_AGENT_PROMPT)
7883
- })).id;
7916
+ let agentId = rows.find((r) => r.name === RELEASE_AGENT_NAME)?.id;
7917
+ if (!agentId) {
7918
+ if (readOnly) return { note: `Release: no "${RELEASE_AGENT_NAME}" Agents row in this workspace \u2014 writes as the ship until a workspace admin runs \`crew agents sync\`` };
7919
+ try {
7920
+ agentId = (await client2.records.create(agentsModel.id, {
7921
+ name: RELEASE_AGENT_NAME,
7922
+ prompt: RELEASE_AGENT_PROMPT,
7923
+ ...promptSourceFields(agentsModel, RELEASE_AGENT_PROMPT)
7924
+ })).id;
7925
+ } catch (e) {
7926
+ if (isForbidden(e)) return { note: `Release: could not create the "${RELEASE_AGENT_NAME}" Agents row (${NO_WRITE_NOTE}) \u2014 writes as the ship` };
7927
+ throw e;
7928
+ }
7929
+ }
7884
7930
  const crewModelId = route.resolved.models.crew;
7885
7931
  const crewModel = await client2.dataModels.get(crewModelId);
7886
- const columnName = await ensureCrewAgentField(route, client2, crewModel, agentsModel.id, userAgent);
7932
+ let columnName;
7933
+ try {
7934
+ columnName = await ensureCrewAgentField(route, client2, crewModel, agentsModel.id, userAgent);
7935
+ } catch (e) {
7936
+ if (e instanceof CrewAgentFieldMissing) return { note: `Release: ${CREW_FIELD_MISSING_NOTE}` };
7937
+ throw e;
7938
+ }
7887
7939
  const seatRow = await client2.records.get(crewModelId, seatId);
7888
7940
  if (seatRow[CREW_AGENT_COLUMN] !== agentId) await client2.records.update(crewModelId, seatId, { [columnName]: agentId });
7889
- return agentId;
7941
+ return { agentId };
7890
7942
  }
7891
- async function linkCrewSeats(route, client2, agentsModelId, agentPersonas, userAgent) {
7943
+ var NO_WRITE_NOTE = "this key has no write access to the Agents table";
7944
+ var CREW_FIELD_MISSING_NOTE = "Crew table has no Agent field yet \u2014 a workspace admin's `crew agents sync` adds it";
7945
+ var CrewAgentFieldMissing = class extends Error {
7946
+ };
7947
+ async function linkCrewSeats(route, client2, agentsModelId, agentPersonas, userAgent, readOnly) {
7892
7948
  const seats2 = route.resolved.seats;
7893
7949
  const rolesWithSeats = ROLE_NAMES.filter((role) => seats2[role] && agentPersonas[role]);
7894
7950
  if (rolesWithSeats.length === 0) {
7951
+ if (readOnly) return ROLE_NAMES.map((role) => ({ role, action: seats2[role] ? "no_agents_row" : "no_seat" }));
7895
7952
  return ROLE_NAMES.map((role) => ({ role, action: seats2[role] ? "persona_diverged" : "no_seat" }));
7896
7953
  }
7897
7954
  const crewModelId = route.resolved.models.crew;
7898
7955
  const crewModel = await client2.dataModels.get(crewModelId);
7899
- const columnName = await ensureCrewAgentField(route, client2, crewModel, agentsModelId, userAgent);
7956
+ let columnName;
7957
+ try {
7958
+ columnName = await ensureCrewAgentField(route, client2, crewModel, agentsModelId, userAgent);
7959
+ } catch (e) {
7960
+ if (e instanceof CrewAgentFieldMissing) {
7961
+ return ROLE_NAMES.map((role) => ({ role, action: seats2[role] ? "no_agent_field" : "no_seat" }));
7962
+ }
7963
+ throw e;
7964
+ }
7900
7965
  const outcomes = [];
7901
7966
  for (const role of ROLE_NAMES) {
7902
7967
  const seatId = seats2[role];
@@ -7906,7 +7971,7 @@ async function linkCrewSeats(route, client2, agentsModelId, agentPersonas, userA
7906
7971
  continue;
7907
7972
  }
7908
7973
  if (!agentId) {
7909
- outcomes.push({ role, action: "persona_diverged" });
7974
+ outcomes.push({ role, action: readOnly ? "no_agents_row" : "persona_diverged" });
7910
7975
  continue;
7911
7976
  }
7912
7977
  const seatRow = await client2.records.get(crewModelId, seatId);
@@ -7947,6 +8012,7 @@ async function ensureCrewAgentField(route, client2, crewModel, agentsModelId, us
7947
8012
  description: "The Agents-table row backing this seat's persona \u2014 set by `crew agents sync` (ISSUE-416), never edited by hand."
7948
8013
  })
7949
8014
  });
8015
+ if (createRes.status === 403) throw new CrewAgentFieldMissing();
7950
8016
  if (!createRes.ok) throw new AgentsSyncError(`creating the Crew table's Agent field failed: ${createRes.status} ${createRes.statusText}`);
7951
8017
  const created = await createRes.json();
7952
8018
  return created.columnName;
@@ -7964,6 +8030,10 @@ function describeSyncOutcome(o) {
7964
8030
  return `${label}: HAS LOCAL EDITS \u2014 crew's default is newer but was NOT applied; review ${o.agentId} in the app before syncing again`;
7965
8031
  case "overridden":
7966
8032
  return `${label}: override in place \u2014 prompt not synced (clear "Customize prompt" on the Agents row to resume)`;
8033
+ case "adopted":
8034
+ return `${label}: using the workspace's existing Agents row (${o.agentId})`;
8035
+ case "missing":
8036
+ return `${label}: no Agents row in this workspace \u2014 writes as the ship until a workspace admin runs \`crew agents sync\``;
7967
8037
  case "forced":
7968
8038
  return `${label}: --force overwrote local edits (previous prompt was: ${JSON.stringify(o.previousPrompt ?? "")})`;
7969
8039
  }
@@ -7977,6 +8047,10 @@ function describeCrewLink(o) {
7977
8047
  return `${label}: Crew row already references its Agents row`;
7978
8048
  case "no_seat":
7979
8049
  return `${label}: no seat configured for this role \u2014 skipped linking`;
8050
+ case "no_agent_field":
8051
+ return `${label}: ${CREW_FIELD_MISSING_NOTE}`;
8052
+ case "no_agents_row":
8053
+ return `${label}: no Agents row to link yet \u2014 a workspace admin's \`crew agents sync\` creates it; run it here afterwards to link this seat`;
7980
8054
  case "persona_diverged":
7981
8055
  return `${label}: persona has local edits and was not synced this pass \u2014 skipped linking until it's resolved`;
7982
8056
  }
@@ -11207,6 +11281,7 @@ async function discover(o) {
11207
11281
  out.role = me.role;
11208
11282
  out.meEmail = me.email;
11209
11283
  out.meId = me.id;
11284
+ out.meName = me.name;
11210
11285
  } catch {
11211
11286
  }
11212
11287
  if (o.project) {
@@ -11430,18 +11505,72 @@ async function discover(o) {
11430
11505
  }
11431
11506
  }
11432
11507
  const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
11433
- out.holds = rows.filter((r) => !seatIds.has(r.id)).map((r) => ({ id: r.id, name: r.name ?? "" }));
11434
- if (out.holds.length === 0) {
11435
- problems.push("no non-seat Crew rows \u2014 which row is the operator?");
11436
- } else if (out.meEmail) {
11437
- const holdRows = rows.filter((r) => !seatIds.has(r.id));
11438
- const matches2 = holdRows.filter((r) => eq(r.email, out.meEmail));
11439
- if (matches2.length === 1) out.operator = matches2[0].id;
11508
+ const holdRows = rows.filter((r) => !seatIds.has(r.id));
11509
+ out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
11510
+ out.crewModelId = out.models.crew;
11511
+ if (!out.meId) {
11512
+ if (out.holds.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
11513
+ else {
11514
+ const byEmail = out.meEmail ? holdRows.filter((r) => eq(r.email, out.meEmail)) : [];
11515
+ if (byEmail.length === 1) out.operator = byEmail[0].id;
11516
+ else if (out.holds.length === 1) out.operator = out.holds[0].id;
11517
+ }
11518
+ } else {
11519
+ const candidates = holdRows.filter((r) => !r.user_id || r.user_id === out.meId);
11520
+ const mine = holdRows.find((r) => r.user_id === out.meId);
11521
+ const byEmail = out.meEmail ? candidates.filter((r) => eq(r.email, out.meEmail)) : [];
11522
+ const only = holdRows.length === 1 && candidates.length === 1 ? candidates[0] : void 0;
11523
+ const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
11524
+ if (pick) {
11525
+ out.operator = pick.id;
11526
+ if (pick.user_id !== out.meId && o.ship) await linkOperatorRow(o, out, pick);
11527
+ } else if (o.ship && (!o.interactive || candidates.length === 0)) {
11528
+ await createOperatorRow(o, out);
11529
+ }
11440
11530
  }
11441
- if (!out.operator && out.holds.length === 1) out.operator = out.holds[0].id;
11442
11531
  }
11443
11532
  return out;
11444
11533
  }
11534
+ async function linkOperatorRow(o, found, row) {
11535
+ const name = row.name || row.id;
11536
+ if (!found.meId || !found.crewModelId) return;
11537
+ if (o.dryRun) {
11538
+ found.provisioning.push(`would link Crew row "${name}" to your account`);
11539
+ return;
11540
+ }
11541
+ const body = { user_id: found.meId };
11542
+ if (!row.email && found.meEmail) body.email = found.meEmail;
11543
+ try {
11544
+ await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
11545
+ found.provisioning.push(`linked Crew row "${name}" to your account`);
11546
+ } catch (e) {
11547
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
11548
+ found.provisioning.push(
11549
+ `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
11550
+ );
11551
+ }
11552
+ }
11553
+ async function createOperatorRow(o, found) {
11554
+ if (!found.meId || !found.crewModelId) return;
11555
+ const name = found.meName || found.meEmail || "Operator";
11556
+ if (o.dryRun) {
11557
+ found.provisioning.push(`would create Crew row "${name}" for you`);
11558
+ return;
11559
+ }
11560
+ try {
11561
+ const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
11562
+ name,
11563
+ ...found.meEmail ? { email: found.meEmail } : {},
11564
+ user_id: found.meId
11565
+ });
11566
+ found.operator = created.id;
11567
+ found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
11568
+ found.provisioning.push(`created Crew row "${name}" for you`);
11569
+ } catch (e) {
11570
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
11571
+ found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
11572
+ }
11573
+ }
11445
11574
  function renderConnection(d, route, dir, opts = {}) {
11446
11575
  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"`;
11447
11576
  const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
@@ -11474,6 +11603,12 @@ import {
11474
11603
  // src/seat-keys.ts
11475
11604
  import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl2 } from "@tablation/client";
11476
11605
  var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
11606
+ function keyIsAdmin(route) {
11607
+ const role = route.resolved?.role;
11608
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
11609
+ }
11610
+ 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";
11611
+ var MEMBER_NO_KEY = "no agent key for this seat \u2014 minting needs a workspace admin key";
11477
11612
  function seatKeySlug(workspaceSlug, agentId) {
11478
11613
  return `${workspaceSlug}#agent:${agentId}`;
11479
11614
  }
@@ -11487,11 +11622,11 @@ function seatAgentId(route, role) {
11487
11622
  async function resolveReleaseKey(route, getStore = getSessionStore2) {
11488
11623
  const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
11489
11624
  const agentId = route.resolved?.releaseAgentId;
11490
- if (!agentId) return ship("release seat has no linked Agents row (run `crew agents sync`)");
11625
+ if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
11491
11626
  try {
11492
11627
  const session = await getStore().get(hostFromUrl2(route.baseUrl), slugFor(route, agentId));
11493
11628
  if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
11494
- return ship("no agent key stored for the release seat (run `crew agents sync`)");
11629
+ return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
11495
11630
  } catch {
11496
11631
  return ship("OS keychain unavailable");
11497
11632
  }
@@ -11500,11 +11635,11 @@ async function resolveSeatKey(route, role, getStore = getSessionStore2) {
11500
11635
  const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
11501
11636
  if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
11502
11637
  const agentId = seatAgentId(route, role);
11503
- if (!agentId) return ship("seat has no linked Agents row (run `crew agents sync`)");
11638
+ if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
11504
11639
  try {
11505
11640
  const session = await getStore().get(hostFromUrl2(route.baseUrl), slugFor(route, agentId));
11506
11641
  if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
11507
- return ship("no agent key stored for this seat (run `crew agents sync`)");
11642
+ return ship(keyIsAdmin(route) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
11508
11643
  } catch {
11509
11644
  return ship("OS keychain unavailable");
11510
11645
  }
@@ -13386,20 +13521,25 @@ function terminalPrompter() {
13386
13521
  }
13387
13522
  };
13388
13523
  }
13389
- async function pickOperator(holds2) {
13524
+ async function pickOperator(holds2, me) {
13525
+ const choices = holds2.filter((h) => !h.user_id || h.user_id === me.id);
13526
+ const canCreate = !!me.id;
13390
13527
  process.stderr.write("\nWhich of these Crew rows is you?\n");
13391
- holds2.forEach((h, i) => process.stderr.write(` ${i + 1}. ${h.name || h.id}
13528
+ choices.forEach((h, i) => process.stderr.write(` ${i + 1}. ${h.name || h.id}
13392
13529
  `));
13530
+ if (canCreate) process.stderr.write(` 0. Create a new Crew row for ${me.name ?? ""} ${me.email ?? ""}`.trimEnd() + "\n");
13393
13531
  const rl = createInterface({ input: process.stdin, output: process.stderr });
13394
13532
  try {
13395
- const answer = (await rl.question("Enter a number (blank to skip): ")).trim();
13533
+ const fallback = canCreate && choices.length === 0 ? "0" : "";
13534
+ const answer = (await rl.question(`Enter a number (blank to ${fallback ? "create one" : "skip"}): `)).trim() || fallback;
13396
13535
  const n = Number(answer);
13397
- if (!answer || !Number.isInteger(n) || n < 1 || n > holds2.length) return void 0;
13398
- return holds2[n - 1].id;
13536
+ if (!answer || !Number.isInteger(n) || n < 0 || n > choices.length) return void 0;
13537
+ return n === 0 ? canCreate ? NEW_OPERATOR_ROW : void 0 : choices[n - 1].id;
13399
13538
  } finally {
13400
13539
  rl.close();
13401
13540
  }
13402
13541
  }
13542
+ var NEW_OPERATOR_ROW = "new";
13403
13543
  async function pickResolvedStatuses(unrecognized) {
13404
13544
  process.stderr.write(
13405
13545
  "\nThis workspace has status values the default contract doesn't know the meaning of.\nFor each: does it mean the ticket is DONE \u2014 resolved/terminal (shipped, closed, won't-fix) \u2014 so a ticket blocked by it should stop being blocked?\n"
@@ -15024,9 +15164,18 @@ ${changed2.map((c) => ` - ${c}
15024
15164
  `);
15025
15165
  process.exit(2);
15026
15166
  }
15167
+ const keyRole = target.resolved.role ?? await fetchKeyRole(target, cfg.ship.userAgent);
15168
+ let readOnly = !isAdminRole(keyRole);
15027
15169
  let personaResult;
15170
+ const runPersonas = (ro) => syncPersonas(target, { userAgent: cfg.ship.userAgent, dryRun, force, readOnly: ro });
15028
15171
  try {
15029
- personaResult = await syncPersonas(target, { userAgent: cfg.ship.userAgent, dryRun, force });
15172
+ try {
15173
+ personaResult = await runPersonas(readOnly);
15174
+ } catch (e) {
15175
+ if (readOnly || !isForbidden(e)) throw e;
15176
+ readOnly = true;
15177
+ personaResult = await runPersonas(true);
15178
+ }
15030
15179
  } catch (e) {
15031
15180
  if (e instanceof AgentsSyncError) {
15032
15181
  process.stderr.write(`crew agents sync: ${e.message}
@@ -15040,28 +15189,32 @@ ${changed2.map((c) => ` - ${c}
15040
15189
  for (const o of personaResult.outcomes) process.stdout.write(`${describeSyncOutcome(o)}
15041
15190
  `);
15042
15191
  for (const o of personaResult.crewLinks) process.stdout.write(`${describeCrewLink(o)}
15192
+ `);
15193
+ if (personaResult.releaseNote) process.stdout.write(`${personaResult.releaseNote}
15043
15194
  `);
15044
15195
  const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
15045
15196
  for (const w of skillMarkerWarnings(target.promptsDir, briefs)) process.stderr.write(`crew agents sync: warning: ${w}
15046
15197
  `);
15047
15198
  let skillResult;
15048
- try {
15049
- skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun, force });
15050
- } catch (e) {
15051
- if (e instanceof SkillSyncError) {
15052
- process.stderr.write(`crew agents sync: ${e.message}
15199
+ if (!readOnly) {
15200
+ try {
15201
+ skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun, force });
15202
+ } catch (e) {
15203
+ if (isForbidden(e)) readOnly = true;
15204
+ else if (e instanceof SkillSyncError) {
15205
+ process.stderr.write(`crew agents sync: ${e.message}
15053
15206
  `);
15054
- process.exit(2);
15207
+ process.exit(2);
15208
+ } else throw e;
15055
15209
  }
15056
- throw e;
15057
15210
  }
15058
- for (const o of skillResult.outcomes) process.stdout.write(`${describeSkillSyncOutcome(o)}
15211
+ for (const o of skillResult?.outcomes ?? []) process.stdout.write(`${describeSkillSyncOutcome(o)}
15059
15212
  `);
15060
15213
  if (!dryRun) {
15061
15214
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15062
15215
  const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
15063
15216
  raw.agentPersonas = personaResult.agentPersonas;
15064
- raw.agentSkills = skillResult.agentSkills;
15217
+ if (skillResult) raw.agentSkills = skillResult.agentSkills;
15065
15218
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
15066
15219
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
15067
15220
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -15071,11 +15224,16 @@ ${changed2.map((c) => ` - ${c}
15071
15224
  target.resolved.agentPersonas = personaResult.agentPersonas;
15072
15225
  if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
15073
15226
  }
15074
- for (const o of await mintMissingSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun, shipName: cfg.ship.name })) {
15075
- process.stdout.write(`${describeMintOutcome(o)}
15227
+ if (readOnly) {
15228
+ process.stdout.write("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)\n");
15229
+ process.stdout.write("seat keys: not minted (needs a workspace admin key)\n");
15230
+ } else {
15231
+ for (const o of await mintMissingSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun, shipName: cfg.ship.name })) {
15232
+ process.stdout.write(`${describeMintOutcome(o)}
15076
15233
  `);
15234
+ }
15077
15235
  }
15078
- if (personaResult.outcomes.some((o) => o.action === "diverged") || skillResult.outcomes.some((o) => o.action === "diverged")) {
15236
+ if (personaResult.outcomes.some((o) => o.action === "diverged") || (skillResult?.outcomes.some((o) => o.action === "diverged") ?? false)) {
15079
15237
  process.exitCode = 1;
15080
15238
  }
15081
15239
  break;
@@ -15444,11 +15602,11 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15444
15602
  sshPublicKey
15445
15603
  };
15446
15604
  const runConnect = async (ws, proj) => {
15447
- let found = await discover({ ...authOpts, workspace: ws, project: proj, area: value("area"), ship, dryRun });
15605
+ let found = await discover({ ...authOpts, workspace: ws, project: proj, area: value("area"), ship, dryRun, interactive: !!(process.stdin.isTTY && process.stdout.isTTY) });
15448
15606
  if (!found.projectId && found.offerTemplateInstall && process.stdin.isTTY && process.stdout.isTTY) {
15449
15607
  const installedSlug = await offerTemplateInstall(authOpts, found.workspaceId);
15450
15608
  if (installedSlug) {
15451
- found = await discover({ ...authOpts, workspace: ws, project: installedSlug, area: value("area"), ship, dryRun });
15609
+ found = await discover({ ...authOpts, workspace: ws, project: installedSlug, area: value("area"), ship, dryRun, interactive: !!(process.stdin.isTTY && process.stdout.isTTY) });
15452
15610
  }
15453
15611
  }
15454
15612
  if (!found.projectId) {
@@ -15474,7 +15632,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15474
15632
  }
15475
15633
  let operator = found.operator;
15476
15634
  if (!operator && found.holds.length > 0 && process.stdin.isTTY && process.stdout.isTTY) {
15477
- operator = await pickOperator(found.holds);
15635
+ const picked = await pickOperator(found.holds, { id: found.meId, name: found.meName, email: found.meEmail });
15636
+ if (picked === NEW_OPERATOR_ROW) {
15637
+ await createOperatorRow({ ...authOpts, dryRun }, found);
15638
+ operator = found.operator;
15639
+ } else if (picked) {
15640
+ operator = picked;
15641
+ const row = found.holds.find((h) => h.id === picked);
15642
+ if (row && found.meId && row.user_id !== found.meId) await linkOperatorRow({ ...authOpts, dryRun }, found, row);
15643
+ }
15478
15644
  }
15479
15645
  let previous;
15480
15646
  const hadPreviousFile = existsSync26(resolvedPath);
@@ -15524,6 +15690,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15524
15690
  seats: found.seats,
15525
15691
  holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
15526
15692
  ...found.releaseSeat ? { releaseSeat: found.releaseSeat } : {},
15693
+ ...found.role ?? previous?.role ? { role: found.role ?? previous?.role } : {},
15527
15694
  ...operator ? { operator } : {},
15528
15695
  ...contract ? { contract } : {},
15529
15696
  ...reviewedStatuses.length > 0 ? { reviewedStatuses } : {},
@@ -16461,16 +16628,20 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16461
16628
  process.stdout.write(
16462
16629
  route.resolved?.releaseSeat ? "release seat: this ship has its own Release Crew row\n" : "release seat: none \u2014 release comments are credited to the dev seat; run `crew connect` once the Crew table has this ship's row\n"
16463
16630
  );
16631
+ const keyRole = route.resolved?.role ?? await fetchKeyRole(route, cfg.ship.userAgent);
16632
+ const roleRoute = route.resolved && keyRole ? { ...route, resolved: { ...route.resolved, role: keyRole } } : route;
16633
+ process.stdout.write(`api key role: ${keyRole ?? "unknown"}
16634
+ `);
16464
16635
  for (const role of SEAT_KEY_ROLES) {
16465
16636
  if (!route.resolved?.seats[role]) continue;
16466
- const id = await resolveSeatKey(route, role);
16637
+ const id = await resolveSeatKey(roleRoute, role);
16467
16638
  process.stdout.write(
16468
16639
  `seat identity: ${role} writes as ${id.kind === "agent" ? `its own agent (${id.agentId})` : `the ship (${id.reason})`}
16469
16640
  `
16470
16641
  );
16471
16642
  }
16472
16643
  if (route.resolved?.releaseSeat) {
16473
- const id = await resolveReleaseKey(route);
16644
+ const id = await resolveReleaseKey(roleRoute);
16474
16645
  process.stdout.write(
16475
16646
  `seat identity: release writes as ${id.kind === "agent" ? `its own agent (${id.agentId})` : `the ship (${id.reason})`}
16476
16647
  `
@@ -16511,7 +16682,7 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
16511
16682
  process.exitCode = 1;
16512
16683
  } else if (!row.user_id) {
16513
16684
  process.stdout.write(
16514
- `operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; link it to the account they sign in with
16685
+ `operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; re-run \`crew connect\` to link it
16515
16686
  `
16516
16687
  );
16517
16688
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.50.2",
3
+ "version": "0.51.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",