@tablation/crew 0.50.2 → 0.50.3

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 +137 -34
  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
  }
@@ -11474,6 +11548,12 @@ import {
11474
11548
  // src/seat-keys.ts
11475
11549
  import { getSessionStore as getSessionStore2, hostFromUrl as hostFromUrl2 } from "@tablation/client";
11476
11550
  var SEAT_KEY_ROLES = ROLE_NAMES.filter((r) => r !== "pair");
11551
+ function keyIsAdmin(route) {
11552
+ const role = route.resolved?.role;
11553
+ return role === void 0 || role === "WORKSPACE_ADMIN" || role === "PLATFORM_ADMIN";
11554
+ }
11555
+ 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";
11556
+ var MEMBER_NO_KEY = "no agent key for this seat \u2014 minting needs a workspace admin key";
11477
11557
  function seatKeySlug(workspaceSlug, agentId) {
11478
11558
  return `${workspaceSlug}#agent:${agentId}`;
11479
11559
  }
@@ -11487,11 +11567,11 @@ function seatAgentId(route, role) {
11487
11567
  async function resolveReleaseKey(route, getStore = getSessionStore2) {
11488
11568
  const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
11489
11569
  const agentId = route.resolved?.releaseAgentId;
11490
- if (!agentId) return ship("release seat has no linked Agents row (run `crew agents sync`)");
11570
+ if (!agentId) return ship(keyIsAdmin(route) ? "release seat has no linked Agents row (run `crew agents sync`)" : `release seat ${MEMBER_NO_ROW}`);
11491
11571
  try {
11492
11572
  const session = await getStore().get(hostFromUrl2(route.baseUrl), slugFor(route, agentId));
11493
11573
  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`)");
11574
+ return ship(keyIsAdmin(route) ? "no agent key stored for the release seat (run `crew agents sync`)" : MEMBER_NO_KEY);
11495
11575
  } catch {
11496
11576
  return ship("OS keychain unavailable");
11497
11577
  }
@@ -11500,11 +11580,11 @@ async function resolveSeatKey(route, role, getStore = getSessionStore2) {
11500
11580
  const ship = (reason) => ({ kind: "ship", apiKey: resolveApiKey(route), reason });
11501
11581
  if (!SEAT_KEY_ROLES.includes(role)) return ship(`${role} always writes as the ship`);
11502
11582
  const agentId = seatAgentId(route, role);
11503
- if (!agentId) return ship("seat has no linked Agents row (run `crew agents sync`)");
11583
+ if (!agentId) return ship(keyIsAdmin(route) ? "seat has no linked Agents row (run `crew agents sync`)" : `seat ${MEMBER_NO_ROW}`);
11504
11584
  try {
11505
11585
  const session = await getStore().get(hostFromUrl2(route.baseUrl), slugFor(route, agentId));
11506
11586
  if (session?.apiKey) return { kind: "agent", apiKey: session.apiKey, agentId };
11507
- return ship("no agent key stored for this seat (run `crew agents sync`)");
11587
+ return ship(keyIsAdmin(route) ? "no agent key stored for this seat (run `crew agents sync`)" : MEMBER_NO_KEY);
11508
11588
  } catch {
11509
11589
  return ship("OS keychain unavailable");
11510
11590
  }
@@ -15024,9 +15104,18 @@ ${changed2.map((c) => ` - ${c}
15024
15104
  `);
15025
15105
  process.exit(2);
15026
15106
  }
15107
+ const keyRole = target.resolved.role ?? await fetchKeyRole(target, cfg.ship.userAgent);
15108
+ let readOnly = !isAdminRole(keyRole);
15027
15109
  let personaResult;
15110
+ const runPersonas = (ro) => syncPersonas(target, { userAgent: cfg.ship.userAgent, dryRun, force, readOnly: ro });
15028
15111
  try {
15029
- personaResult = await syncPersonas(target, { userAgent: cfg.ship.userAgent, dryRun, force });
15112
+ try {
15113
+ personaResult = await runPersonas(readOnly);
15114
+ } catch (e) {
15115
+ if (readOnly || !isForbidden(e)) throw e;
15116
+ readOnly = true;
15117
+ personaResult = await runPersonas(true);
15118
+ }
15030
15119
  } catch (e) {
15031
15120
  if (e instanceof AgentsSyncError) {
15032
15121
  process.stderr.write(`crew agents sync: ${e.message}
@@ -15040,28 +15129,32 @@ ${changed2.map((c) => ` - ${c}
15040
15129
  for (const o of personaResult.outcomes) process.stdout.write(`${describeSyncOutcome(o)}
15041
15130
  `);
15042
15131
  for (const o of personaResult.crewLinks) process.stdout.write(`${describeCrewLink(o)}
15132
+ `);
15133
+ if (personaResult.releaseNote) process.stdout.write(`${personaResult.releaseNote}
15043
15134
  `);
15044
15135
  const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
15045
15136
  for (const w of skillMarkerWarnings(target.promptsDir, briefs)) process.stderr.write(`crew agents sync: warning: ${w}
15046
15137
  `);
15047
15138
  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}
15139
+ if (!readOnly) {
15140
+ try {
15141
+ skillResult = await syncSkills(target, { userAgent: cfg.ship.userAgent, dryRun, force });
15142
+ } catch (e) {
15143
+ if (isForbidden(e)) readOnly = true;
15144
+ else if (e instanceof SkillSyncError) {
15145
+ process.stderr.write(`crew agents sync: ${e.message}
15053
15146
  `);
15054
- process.exit(2);
15147
+ process.exit(2);
15148
+ } else throw e;
15055
15149
  }
15056
- throw e;
15057
15150
  }
15058
- for (const o of skillResult.outcomes) process.stdout.write(`${describeSkillSyncOutcome(o)}
15151
+ for (const o of skillResult?.outcomes ?? []) process.stdout.write(`${describeSkillSyncOutcome(o)}
15059
15152
  `);
15060
15153
  if (!dryRun) {
15061
15154
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
15062
15155
  const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync19(resolvedPath, "utf8")) : {};
15063
15156
  raw.agentPersonas = personaResult.agentPersonas;
15064
- raw.agentSkills = skillResult.agentSkills;
15157
+ if (skillResult) raw.agentSkills = skillResult.agentSkills;
15065
15158
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
15066
15159
  mkdirSync13(dirOf(resolvedPath), { recursive: true });
15067
15160
  writeFileSync14(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -15071,11 +15164,16 @@ ${changed2.map((c) => ` - ${c}
15071
15164
  target.resolved.agentPersonas = personaResult.agentPersonas;
15072
15165
  if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
15073
15166
  }
15074
- for (const o of await mintMissingSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun, shipName: cfg.ship.name })) {
15075
- process.stdout.write(`${describeMintOutcome(o)}
15167
+ if (readOnly) {
15168
+ 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");
15169
+ process.stdout.write("seat keys: not minted (needs a workspace admin key)\n");
15170
+ } else {
15171
+ for (const o of await mintMissingSeatKeys(target, { userAgent: cfg.ship.userAgent, dryRun, shipName: cfg.ship.name })) {
15172
+ process.stdout.write(`${describeMintOutcome(o)}
15076
15173
  `);
15174
+ }
15077
15175
  }
15078
- if (personaResult.outcomes.some((o) => o.action === "diverged") || skillResult.outcomes.some((o) => o.action === "diverged")) {
15176
+ if (personaResult.outcomes.some((o) => o.action === "diverged") || (skillResult?.outcomes.some((o) => o.action === "diverged") ?? false)) {
15079
15177
  process.exitCode = 1;
15080
15178
  }
15081
15179
  break;
@@ -15524,6 +15622,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15524
15622
  seats: found.seats,
15525
15623
  holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
15526
15624
  ...found.releaseSeat ? { releaseSeat: found.releaseSeat } : {},
15625
+ ...found.role ?? previous?.role ? { role: found.role ?? previous?.role } : {},
15527
15626
  ...operator ? { operator } : {},
15528
15627
  ...contract ? { contract } : {},
15529
15628
  ...reviewedStatuses.length > 0 ? { reviewedStatuses } : {},
@@ -16461,16 +16560,20 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
16461
16560
  process.stdout.write(
16462
16561
  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
16562
  );
16563
+ const keyRole = route.resolved?.role ?? await fetchKeyRole(route, cfg.ship.userAgent);
16564
+ const roleRoute = route.resolved && keyRole ? { ...route, resolved: { ...route.resolved, role: keyRole } } : route;
16565
+ process.stdout.write(`api key role: ${keyRole ?? "unknown"}
16566
+ `);
16464
16567
  for (const role of SEAT_KEY_ROLES) {
16465
16568
  if (!route.resolved?.seats[role]) continue;
16466
- const id = await resolveSeatKey(route, role);
16569
+ const id = await resolveSeatKey(roleRoute, role);
16467
16570
  process.stdout.write(
16468
16571
  `seat identity: ${role} writes as ${id.kind === "agent" ? `its own agent (${id.agentId})` : `the ship (${id.reason})`}
16469
16572
  `
16470
16573
  );
16471
16574
  }
16472
16575
  if (route.resolved?.releaseSeat) {
16473
- const id = await resolveReleaseKey(route);
16576
+ const id = await resolveReleaseKey(roleRoute);
16474
16577
  process.stdout.write(
16475
16578
  `seat identity: release writes as ${id.kind === "agent" ? `its own agent (${id.agentId})` : `the ship (${id.reason})`}
16476
16579
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.50.2",
3
+ "version": "0.50.3",
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",