@awebai/oats 0.27.0 → 0.27.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.
@@ -17,7 +17,7 @@
17
17
  * OATS_CONTEXT resolution context dir (the soul's repo / agents root parent)
18
18
  * OATS_WORKSPACE the agents root's parent — the team boundary
19
19
  * OATS_SETTINGS JSON of the provider's `settings:` block
20
- * OATS_TEAM_NAME/OATS_TEAM_ID/OATS_TEAM_SCOPE resolved config `team:` block (may be empty)
20
+ * OATS_TEAM_ID/OATS_TEAM_LABEL/OATS_TEAM_LABELS/OATS_TEAMS workspace team facts (may be empty)
21
21
  * OATS_META JSON persisted from this hook's previous spawn output (retire only)
22
22
  *
23
23
  * Output (spawn, stdout JSON):
@@ -74,6 +74,8 @@ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env
74
74
  const why = secretSafe ? "" : (scrub(e.stderr).trim() || (e.status === undefined ? String(e.code || "failed") : ""));
75
75
  const err = new Error(`${where} failed${e.status === undefined ? "" : ` (exit ${e.status})`}${why ? `: ${why}` : ""}${secretSafe ? " (output withheld: this command handles credentials)" : ""}`);
76
76
  err.status = e.status;
77
+ err.stdout = scrub(e.stdout).trim();
78
+ err.stderr = scrub(e.stderr).trim();
77
79
  // A classification, never the text: the caller may name a KNOWN failure
78
80
  // class (an alias that still holds a certificate) without any output of a
79
81
  // credential-handling command reaching a log.
@@ -158,10 +160,13 @@ try {
158
160
 
159
161
  const event = process.env.OATS_EVENT || process.argv[2];
160
162
  const instance = process.env.OATS_INSTANCE;
161
- const home = process.env.OATS_HOME || process.cwd();
163
+ let home = process.env.OATS_HOME || process.cwd();
162
164
  const AWEB_ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
163
165
  const AWEB_ALIAS_RULE = "invalid alias: aweb aliases must be 1-64 characters, start with a letter or digit, and then contain only letters, digits, '-' or '_'";
164
166
  const ALIAS_REUSE_REMEDY = "spawn with a different --name (kernels 0.26.0+) or a different --purpose";
167
+ const CLASSIC_REFUSAL = "oats.aweb 1.14 needs OATS 0.26.0 or newer (workspace model); on an older kernel pin oats.aweb v1.13.x";
168
+ const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);
169
+ const isClassicEnvironment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
165
170
  // Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
166
171
  // delivery: "channel" (default) keeps the native channel packages waking the
167
172
  // instance; "session" hands delivery to the host wake broker (aweb-abil):
@@ -175,51 +180,18 @@ const deliveryMode = (() => {
175
180
  })();
176
181
  const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
177
182
  const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
183
+ if (isClassicEnvironment() && ["spawn", "setup"].includes(event)) {
184
+ if (event === "setup") { console.error(CLASSIC_REFUSAL); process.exit(1); }
185
+ fatal(CLASSIC_REFUSAL);
186
+ }
178
187
  if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
179
188
  if (event === "spawn" && identityMode === "local" && !identitySettings.source && (!instance || !AWEB_ALIAS_RE.test(instance))) fatal(`${AWEB_ALIAS_RULE}; OATS_INSTANCE is ${instance ? "not valid" : "missing"}, so no identity could be minted`);
180
189
  const payloadTeam = () => {
181
190
  const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
182
- const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
183
- return { team: fromSettings || fromEnv, payload: fromSettings, env: fromEnv };
191
+ return { team: fromSettings, payload: fromSettings, env: process.env.OATS_TEAM_ID || undefined };
184
192
  };
185
193
  const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
186
-
187
- /**
188
- * The aweb root (minting authority). BOUNDED candidates — the deployment's team
189
- * scope (from config `team:`) is the natural home; we never walk past the
190
- * workspace to the laptop root (a `.aw` there would be a different team;
191
- * minting into it would be a silent cross-team leak):
192
- * 1. the declared team scope (OATS_TEAM_SCOPE)
193
- * 2. the instance home itself
194
- * 3. the git repo root containing the home (if any)
195
- * 4. the resolution context (the soul's target repo) and its git repo root
196
- * 5. the workspace root (OATS_WORKSPACE — e.g. ~/lfx)
197
- * First candidate with a `.aw` wins; none → no minting.
198
- */
199
- function gitRootOf(startDir) {
200
- let d = resolve(startDir);
201
- while (true) {
202
- if (existsSync(join(d, ".git"))) return d;
203
- const parent = dirname(d);
204
- if (parent === d) return undefined;
205
- d = parent;
206
- }
207
- }
208
- function classicAwebRoot() {
209
- const candidates = [];
210
- const push = (p) => { if (p && !candidates.includes(resolve(p))) candidates.push(resolve(p)); };
211
- push(process.env.OATS_TEAM_SCOPE);
212
- push(home);
213
- push(gitRootOf(home));
214
- push(process.env.OATS_CONTEXT);
215
- if (process.env.OATS_CONTEXT) push(gitRootOf(process.env.OATS_CONTEXT));
216
- push(process.env.OATS_WORKSPACE);
217
- for (const c of candidates) if (existsSync(join(c, ".aw"))) return c;
218
- return undefined;
219
- }
220
- const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);
221
- const isClassicDeployment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
222
- const teamConfigRemedy = () => `set messaging.byTeam.<label>.team in the workspace file or settings.oats.aweb.team${isClassicDeployment() ? " (classic: set team.id in oats-config.yaml)" : ""}`;
194
+ const teamConfigRemedy = () => "set settings.oats.aweb.team or keep an active team at the aweb root";
223
195
  function declaredRootCandidate(team = payloadTeam().team) {
224
196
  const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
225
197
  if (team && typeof roots[team] === "string" && roots[team].trim()) return { root: roots[team].trim(), key: `settings.oats.aweb.roots[${JSON.stringify(team)}]`, declared: true };
@@ -229,7 +201,7 @@ function declaredRootCandidate(team = payloadTeam().team) {
229
201
  function rootSettingCandidate(team = payloadTeam().team) {
230
202
  const declared = declaredRootCandidate(team);
231
203
  if (declared) return declared;
232
- const fallback = process.env.OATS_WORKSPACE || (!isClassicDeployment() ? process.env.OATS_TEAM_SCOPE : undefined) || process.cwd();
204
+ const fallback = process.env.OATS_WORKSPACE || process.cwd();
233
205
  return fallback ? { root: fallback, key: "settings.oats.aweb.root", declared: false } : undefined;
234
206
  }
235
207
  function awebRootProblem(candidate) {
@@ -241,7 +213,6 @@ function resolveAwebRoot() {
241
213
  const declared = declaredRootCandidate();
242
214
  if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
243
215
  if (declared?.declared) return undefined;
244
- if (isClassicDeployment()) return classicAwebRoot();
245
216
  const candidate = rootSettingCandidate();
246
217
  if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
247
218
  return undefined;
@@ -257,7 +228,7 @@ const teamMemberships = (listed) => listed?.memberships || listed?.teams || [];
257
228
  const teamIdsOf = (listed) => teamMemberships(listed).map((m) => m.team_id || m.id || m);
258
229
 
259
230
  const AW_INSTALL = "install the aw CLI first — see https://aweb.ai/docs (or `oats aweb setup` for guided onboarding)";
260
- const isCommand = ["roster", "setup"].includes(event);
231
+ const isCommand = ["roster", "setup", "teams", "join", "leave"].includes(event);
261
232
  if (!onPath("aw")) {
262
233
  if (isCommand) { console.error(`oats aweb ${event}: aw CLI not on PATH — ${AW_INSTALL}`); process.exit(1); }
263
234
  if (event === "spawn") fatal(`aw CLI not on PATH, so no identity could be minted and this instance would have no messaging — ${AW_INSTALL}`);
@@ -477,10 +448,15 @@ function globalGrantRenew() {
477
448
  out({ meta: newMeta, ...retainedLaunchOutput(newMeta, grantHome), ...(warning ? { warning } : {}) });
478
449
  }
479
450
  function globalGrantSpawn() {
480
- const { team, payload, env: envTeam } = payloadTeam();
481
- if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
451
+ const resolvedTeam = payloadTeam();
482
452
  const resident = String(identitySettings.resident || "");
483
453
  const custody = resolveResidentCustody(resident);
454
+ let team = resolvedTeam.team;
455
+ const teamWarnings = [];
456
+ const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
457
+ if (!team) team = activeTeamAt(custody);
458
+ if (unmappedPrimary && team) teamWarnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
459
+ if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team or an active team at the resident custody root before minting a grant");
484
460
  const grantHome = join(home, ".aweb-identity");
485
461
  if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
486
462
  const scopes = grantScopes();
@@ -528,8 +504,7 @@ function globalGrantSpawn() {
528
504
  catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
529
505
  }
530
506
  }
531
- const warnings = [...preflight.warnings];
532
- if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
507
+ const warnings = [...teamWarnings, ...preflight.warnings];
533
508
  const e2eeBrief = preflight.warnings.length ? ` Warning: ${preflight.warnings.join(" ")}` : "";
534
509
  const deliveryBrief = deliveryMode === "session"
535
510
  ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
@@ -573,10 +548,32 @@ const workspaceAliasOf = (homeDir) => {
573
548
  * signing key, a team certificate and a workspace binding. */
574
549
  const joinedLate = (homeDir) => existsSync(join(homeDir, ".aw", "signing.key")) && existsSync(join(homeDir, ".aw", "team-certs")) && !!workspaceAliasOf(homeDir);
575
550
  const JOIN_TIMEOUT_MS = Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) > 0 ? Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) : 120000;
551
+ // The first aw whose joined-team external homes are fully operable: local
552
+ // accept-invite under --identity-home, hosted workspace auto-connect, self-release
553
+ // and joined-root E2E key publication.
554
+ const JOINED_TEAMS_AW_MIN = "1.36.12";
555
+ function joinedTeamsAwFloorProblem() {
556
+ if (/^\d+\.\d+\.\d+$/.test(JOINED_TEAMS_AW_MIN) && awAtLeast(JOINED_TEAMS_AW_MIN)) return undefined;
557
+ if (!/^\d+\.\d+\.\d+$/.test(JOINED_TEAMS_AW_MIN)) return `E_TEAM_AW_FLOOR: joined-team identities need an aw release that admits local accept-invite under --identity-home, auto-connects the joined workspace and publishes the joined-root E2E key; installed aw is ${awVersionLabel()}. join= and oats aweb join are gated until that aw release exists`;
558
+ return `E_TEAM_AW_FLOOR: joined-team identities require aw >= ${JOINED_TEAMS_AW_MIN}; installed aw is ${awVersionLabel()}. join= and oats aweb join are gated until aw admits local accept-invite under --identity-home, auto-connects the joined workspace and publishes the joined-root E2E key`;
559
+ }
560
+ function requireJoinedTeamsAwFloor() {
561
+ const problem = joinedTeamsAwFloorProblem();
562
+ if (!problem) return;
563
+ const error = new Error(problem.replace(/^E_TEAM_AW_FLOOR: /, ""));
564
+ error.code = "E_TEAM_AW_FLOOR";
565
+ throw error;
566
+ }
576
567
  const yamlScalar = (text, key) => {
577
568
  const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
578
569
  return m ? m[1].trim() : undefined;
579
570
  };
571
+ function activeTeamAt(root) {
572
+ try {
573
+ const text = readFileSync(join(resolve(root), ".aw", "teams.yaml"), "utf8");
574
+ return yamlScalar(text, "active_team") || yamlScalar(text, "active");
575
+ } catch { return undefined; }
576
+ }
580
577
  function retainedSeatSpawn(source, takeOver) {
581
578
  if (typeof source !== "string" || !source.startsWith("/")) fatal("identity.source must be the absolute path of the legacy .aw directory to retain");
582
579
  if (!existsSync(join(source, "signing.key"))) fatal(`identity.source ${source} holds no signing.key, so there is no identity to retain`);
@@ -699,19 +696,209 @@ function retainedSeatSpawn(source, takeOver) {
699
696
  }
700
697
  }
701
698
 
699
+ const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
700
+ function parseOatsTeams(env = process.env) {
701
+ try {
702
+ const rows = JSON.parse(env.OATS_TEAMS || "[]");
703
+ return Array.isArray(rows) ? rows.filter((r) => r && typeof r === "object").map((r) => ({ label: String(r.label || ""), team: typeof r.team === "string" ? r.team : null, mapped: r.mapped === true, payload: r.payload && typeof r.payload === "object" ? r.payload : {} })) : [];
704
+ } catch { return []; }
705
+ }
706
+ function eligibleTeams() { return parseOatsTeams().filter((t) => t.mapped && t.label && t.team); }
707
+ function labelsCsv(text) { return String(text || "").split(",").map((s) => s.trim()).filter(Boolean); }
708
+ function primaryTeamLabel() { return process.env.OATS_TEAM_LABEL || labelsCsv(process.env.OATS_TEAM_LABELS)[0] || null; }
709
+ function requestedJoinLabels() { return labelsCsv(settings.join); }
710
+ function unmappedPrimaryRow() {
711
+ const primary = primaryTeamLabel();
712
+ return primary ? parseOatsTeams().find((t) => t.label === primary && !t.mapped) : undefined;
713
+ }
714
+ function validateJoinLabels(labels, { action = "join" } = {}) {
715
+ const eligible = eligibleTeams();
716
+ const byLabel = new Map(eligible.map((t) => [t.label, t]));
717
+ for (const label of labels) {
718
+ if (action === "leave" && label === "personal") {
719
+ const error = new Error(`E_TEAM_PERSONAL: ${label} is the personal team and cannot be left`);
720
+ error.code = "E_TEAM_PERSONAL";
721
+ throw error;
722
+ }
723
+ if (!LABEL_RE.test(label) || !byLabel.has(label)) {
724
+ const choices = eligible.map((t) => t.label).join(", ") || "(none)";
725
+ const error = new Error(`E_TEAM_NOT_ELIGIBLE: ${label} is not an eligible team label for this instance (eligible: ${choices})`);
726
+ error.code = "E_TEAM_NOT_ELIGIBLE";
727
+ throw error;
728
+ }
729
+ }
730
+ return labels.map((label) => byLabel.get(label));
731
+ }
732
+ const joinedTeamsOf = (meta = {}) => Array.isArray(meta.joinedTeams) ? meta.joinedTeams.filter((j) => j && typeof j === "object" && j.label && j.team && j.identityHome) : [];
733
+ const providerStateDir = () => join(home, ".oats-aweb");
734
+ const providerTeamsFile = () => join(providerStateDir(), "teams.json");
735
+ function readProviderTeamsState(meta = {}) {
736
+ try {
737
+ const doc = JSON.parse(readFileSync(providerTeamsFile(), "utf8"));
738
+ return { joinedTeams: joinedTeamsOf(doc) };
739
+ } catch { return { joinedTeams: joinedTeamsOf(meta) }; }
740
+ }
741
+ function writeProviderTeamsState(meta) {
742
+ mkdirSync(providerStateDir(), { recursive: true, mode: 0o700 });
743
+ writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta) }, null, 2) + "\n", { mode: 0o600 });
744
+ }
745
+ function withProviderTeams(meta = {}) { return { ...meta, joinedTeams: readProviderTeamsState(meta).joinedTeams }; }
746
+ function identityHomeForLabel(label) { return join(home, `.aweb-identity-${label}`); }
747
+ function awWithIdentity(identityHome, args) { return ["aw", "--identity-home", identityHome, ...args]; }
748
+ function commandOutput(e) { return [e?.stdout, e?.stderr, e?.message].filter(Boolean).join("\n"); }
749
+ function workspaceConnectRecovery(text) {
750
+ const m = String(text || "").match(/aw\s+--identity-home\s+\S+\s+workspace\s+connect\s+--service\s+\S+(?:\s+--json)?|aw\s+workspace\s+connect\s+--service\s+\S+(?:\s+--json)?|workspace\s+connect\s+--service\s+\S+(?:\s+--json)?/i);
751
+ return m ? m[0].trim() : undefined;
752
+ }
753
+ function joinedWorkspacePresent(identityHome) { return existsSync(join(identityHome, "workspace.yaml")); }
754
+ function releaseReceiptStatus(doc) {
755
+ return doc && typeof doc === "object" && doc.alias_released === true ? "released" : undefined;
756
+ }
757
+ function readCapabilityMeta() {
758
+ if (process.env.OATS_META) { try { return withProviderTeams(JSON.parse(process.env.OATS_META || "{}")); } catch { return withProviderTeams({}); } }
759
+ try { return withProviderTeams(JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).capabilityMeta?.["oats.aweb"] || {}); } catch { return withProviderTeams({}); }
760
+ }
761
+ function teamsDocument(meta = readCapabilityMeta()) {
762
+ const teams = parseOatsTeams();
763
+ const joined = joinedTeamsOf(meta);
764
+ const joinedLabels = new Set(joined.map((j) => j.label));
765
+ const primary = primaryTeamLabel();
766
+ const personalTeam = meta.team || meta.identity?.team || payloadTeam().team || null;
767
+ return {
768
+ personal: { team: personalTeam },
769
+ primary,
770
+ eligible: teams.filter((t) => t.mapped && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label) })),
771
+ joined: joined.map((j) => ({ label: j.label, team: j.team, identityHome: j.identityHome, receive: j.receive || "poll", since: j.since })),
772
+ unmapped: teams.filter((t) => !t.mapped).map((t) => t.label),
773
+ at: new Date().toISOString(),
774
+ };
775
+ }
776
+ function mintJoinedTeam(row, meta) {
777
+ const existing = joinedTeamsOf(meta).find((j) => j.label === row.label);
778
+ if (existing) return { meta, joined: existing, changed: false };
779
+ requireJoinedTeamsAwFloor();
780
+ const identityHome = identityHomeForLabel(row.label);
781
+ const root = awebRootForTeam(row.team);
782
+ if (!root) throw new Error(`${awebRootProblem(rootSettingCandidate(row.team))}, so team ${row.label} could not be joined`);
783
+ const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", row.team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
784
+ if (!inv?.token || typeof inv.token !== "string") throw new Error(`aw team invite returned no usable token for ${row.label}`);
785
+ let raw, acceptWarning;
786
+ try {
787
+ raw = parseSecretJson(run(awWithIdentity(identityHome, ["id", "team", "accept-invite", inv.token, "--name", instance || meta.alias, "--local", "--json"]), home, JOIN_TIMEOUT_MS, { secrets: [inv.token], secretSafe: true }), "aw id team accept-invite");
788
+ } catch (e) {
789
+ const output = commandOutput(e);
790
+ const recovery = workspaceConnectRecovery(output);
791
+ let accepted;
792
+ try { accepted = parseAwJson(e.stdout || "", "aw id team accept-invite"); } catch { accepted = undefined; }
793
+ if (!accepted?.team_id) throw new Error(`aw id team accept-invite failed for joined team ${row.label}${recovery ? `; recovery: ${recovery}` : ""}`);
794
+ raw = accepted;
795
+ acceptWarning = `joined team ${row.label} was accepted but workspace connect failed${recovery ? `; recovery: ${recovery}` : "; rerun the aw workspace connect recovery command printed by aw"}`;
796
+ }
797
+ const alias = typeof raw.alias === "string" && AWEB_ALIAS_RE.test(raw.alias) ? raw.alias : (instance || meta.alias);
798
+ const team = typeof raw.team_id === "string" && raw.team_id ? raw.team_id : row.team;
799
+ if (team !== row.team) throw new Error(`joined team ${team} differs from requested ${row.team}`);
800
+ const joined = { label: row.label, team, identityHome, receive: "poll", since: new Date().toISOString(), alias };
801
+ const next = { ...meta, joinedTeams: [...joinedTeamsOf(meta), joined] };
802
+ if (!joinedWorkspacePresent(identityHome)) {
803
+ const recovery = workspaceConnectRecovery(JSON.stringify(raw)) || raw.recovery_command || raw.recoveryCommand;
804
+ return { meta: next, joined, changed: true, warning: acceptWarning || `joined team ${row.label} was accepted but no workspace connection was written under ${identityHome}${recovery ? `; recovery: ${recovery}` : "; rerun the aw workspace connect recovery command printed by aw"}` };
805
+ }
806
+ return { meta: next, joined, changed: true, ...(acceptWarning ? { warning: acceptWarning } : {}) };
807
+ }
808
+ function leaveJoinedTeam(label, meta) {
809
+ const joined = joinedTeamsOf(meta);
810
+ const entry = joined.find((j) => j.label === label);
811
+ if (!entry) return { meta, changed: false };
812
+ let receipt, released;
813
+ try {
814
+ receipt = parseAwJson(run(awWithIdentity(entry.identityHome, ["workspace", "delete", entry.alias || meta.alias || instance, "--json"]), home, 60000), "aw workspace delete");
815
+ released = releaseReceiptStatus(receipt);
816
+ } catch (e) {
817
+ throw new Error(`failed to leave team ${label}; kept ${entry.identityHome} so cleanup can be retried: ${String(commandOutput(e)).slice(0, 300)}`);
818
+ }
819
+ if (!released) throw new Error(`failed to leave team ${label}; workspace delete did not report alias_released: true; kept ${entry.identityHome} so cleanup can be retried: ${JSON.stringify(receipt)}`);
820
+ try { rmSync(entry.identityHome, { recursive: true, force: true }); } catch { /* best effort */ }
821
+ return { meta: { ...meta, joinedTeams: joined.filter((j) => j.label !== label) }, changed: true, released, receipt };
822
+ }
823
+ function awebRootForTeam(team) {
824
+ const declared = declaredRootCandidate(team);
825
+ if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
826
+ if (declared?.declared) return undefined;
827
+ const candidate = rootSettingCandidate(team);
828
+ if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
829
+ return undefined;
830
+ }
831
+ function parseHomeCommandArgs(argv = process.argv.slice(3)) {
832
+ const rest = [];
833
+ let json = false, labels;
834
+ for (let i = 0; i < argv.length; i++) {
835
+ const arg = argv[i];
836
+ if (arg === "--json") { json = true; continue; }
837
+ if (arg === "--home" && argv[i + 1]) { home = resolve(argv[++i]); process.env.OATS_HOME = home; continue; }
838
+ if (arg.startsWith("--home=")) { home = resolve(arg.slice("--home=".length)); process.env.OATS_HOME = home; continue; }
839
+ if (arg === "--labels" && argv[i + 1]) { labels = argv[++i]; continue; }
840
+ if (arg.startsWith("--labels=")) { labels = arg.slice("--labels=".length); continue; }
841
+ rest.push(arg);
842
+ }
843
+ if (!labels && rest[0]) labels = rest[0];
844
+ return { json, labels: labelsCsv(labels) };
845
+ }
846
+ function outputTeamsDocument(doc, json) {
847
+ if (json) { console.log(JSON.stringify(doc)); return; }
848
+ console.log(`personal: ${doc.personal.team || "unknown"}`);
849
+ for (const row of doc.eligible) console.log(`${row.joined ? "joined" : "eligible"}: ${row.label} (${row.team})`);
850
+ for (const label of doc.unmapped) console.log(`unmapped: ${label}`);
851
+ }
852
+ function runTeamsCommand(kind) {
853
+ const args = parseHomeCommandArgs();
854
+ let meta = readCapabilityMeta();
855
+ const actions = [];
856
+ const warnings = [];
857
+ if (kind === "join" || kind === "leave") {
858
+ const rows = validateJoinLabels(args.labels, { action: kind });
859
+ if (!rows.length) throw new Error("labels are required");
860
+ for (const row of rows) {
861
+ const result = kind === "join" ? mintJoinedTeam(row, meta) : leaveJoinedTeam(row.label, meta);
862
+ meta = result.meta;
863
+ actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(result.warning ? { warning: result.warning } : {}) });
864
+ if (result.warning) warnings.push(`oats-aweb: ${result.warning}`);
865
+ }
866
+ writeProviderTeamsState(meta);
867
+ }
868
+ const doc = { ...teamsDocument(meta), ...(actions.length ? { actions } : {}), ...(warnings.length ? { warnings } : {}) };
869
+ outputTeamsDocument(doc, args.json);
870
+ }
871
+
702
872
  if (event === "launch") {
703
873
  if (identityMode === "global" || grantRenewMode() === "launch") globalGrantRenew();
704
- out(retainedLaunchOutput(JSON.parse(process.env.OATS_META || "{}")));
874
+ let oldMeta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
875
+ const joined = joinedTeamsOf(oldMeta);
876
+ if (joined.length && process.env.OATS_TEAMS_SOURCE === "live") {
877
+ const eligible = new Set(eligibleTeams().map((t) => t.label));
878
+ let changed = false;
879
+ const warnings = [];
880
+ for (const row of joined) if (!eligible.has(row.label)) {
881
+ try { oldMeta = leaveJoinedTeam(row.label, oldMeta).meta; changed = true; }
882
+ catch (e) { warnings.push(`joined team ${row.label} cleanup failed: ${e.message || e}`); }
883
+ }
884
+ if (changed) writeProviderTeamsState(oldMeta);
885
+ out({ ...(changed ? { meta: oldMeta } : {}), ...retainedLaunchOutput(oldMeta), ...(warnings.length ? { warning: `oats-aweb: ${warnings.join(" | ")}` } : {}) });
886
+ }
887
+ if (joined.length && process.env.OATS_TEAMS_SOURCE !== "live") out({ ...retainedLaunchOutput(oldMeta), warning: "oats-aweb: teams-unverified — keeping joined team memberships because live eligible teams are unavailable" });
888
+ out(retainedLaunchOutput(oldMeta));
705
889
  } else if (event === "spawn") {
706
890
  if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
891
+ if (identityMode === "global" && requestedJoinLabels().length) fatal('settings.oats.aweb.join is supported only with local per-team identities; identity.mode "global" is explicit resident-grant mode');
707
892
  if (identityMode === "global") globalGrantSpawn();
708
893
  if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
894
+ let joinRows;
895
+ try { joinRows = validateJoinLabels(requestedJoinLabels()); }
896
+ catch (e) { fatal(e.message || e); }
709
897
  let minted; // external identity, once `aw team join` succeeds
710
898
  const root = awebRoot();
711
899
  if (!root) {
712
900
  const candidate = rootSettingCandidate();
713
- const classicHint = isClassicDeployment() ? " (classic fallback also checked the bounded team-scope candidates)" : "";
714
- fatal(`${awebRootProblem(candidate)}, so no identity could be minted and this instance would have no messaging${classicHint}`);
901
+ fatal(`${awebRootProblem(candidate)}, so no identity could be minted and this instance would have no messaging`);
715
902
  }
716
903
  try {
717
904
  // Team correctness: the config's `team:` block wins (id, then name), else the
@@ -721,9 +908,10 @@ if (event === "launch") {
721
908
  // cross-machine instance directory).
722
909
  const resolvedTeam = payloadTeam();
723
910
  let team = resolvedTeam.team;
724
- const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
725
- if (!team && process.env.OATS_TEAM_LABEL) fatal(`cannot determine target team for workspace team label ${JSON.stringify(process.env.OATS_TEAM_LABEL)}, so no identity could be minted — ${teamConfigRemedy()}`);
911
+ const warnings = [];
912
+ const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
726
913
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
914
+ if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
727
915
  if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
728
916
  // A bare team name (no namespace) resolves against the root's memberships.
729
917
  if (!team.includes(":")) {
@@ -779,7 +967,7 @@ if (event === "launch") {
779
967
  run(["aw", "init", "--do-not-touch-agents-md"], home);
780
968
  const alias = joined.alias;
781
969
  const mismatch = joined.team_id !== team
782
- ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : teamPayloadMismatch ? ` [WARNING: settings team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team]` : "";
970
+ ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : "";
783
971
  // Runtime integration: for Claude Code sessions the aweb-channel plugin
784
972
  // carries real-time push events. This hook does NOT install it. The plugin
785
973
  // is a DECLARED runtime requirement (oats.json), consented once at
@@ -799,12 +987,17 @@ if (event === "launch") {
799
987
  const deliveryBrief = deliveryMode === "session"
800
988
  ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
801
989
  : "";
990
+ let meta = { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
991
+ const joinFloorProblem = joinRows.length ? joinedTeamsAwFloorProblem() : undefined;
992
+ if (joinFloorProblem) warnings.push(`oats-aweb: ${joinFloorProblem}`);
993
+ else for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
994
+ writeProviderTeamsState(meta);
802
995
  out({
803
- meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
996
+ meta,
804
997
  env,
805
- brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
998
+ brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Joined team identities receive by polling in oats.aweb 1.14; run \`oats aweb teams --json\` for identity homes. Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
806
999
  ...(launch ? { launch } : {}),
807
- ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : teamPayloadMismatch ? { warning: `oats-aweb: settings.oats.aweb.team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team` } : channelWarning ? { warning: channelWarning } : {}),
1000
+ ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : warnings.length ? { warning: warnings.join(" | ") } : channelWarning ? { warning: channelWarning } : {}),
808
1001
  });
809
1002
  } catch (e) {
810
1003
  // A join may already have created a REMOTE identity before the failure.
@@ -813,16 +1006,23 @@ if (event === "launch") {
813
1006
  fatal(`identity minting failed: ${e.message || e}`, minted);
814
1007
  }
815
1008
  } else if (event === "retire") {
816
- let meta = JSON.parse(process.env.OATS_META || "{}");
1009
+ let meta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
1010
+ const retireWarnings = [];
817
1011
  // A retained seat: release the lock and leave the identity alone. Never
818
1012
  // aw workspace delete (it would soft-delete the standing identity's row)
819
1013
  // and never team retire; the source .aw stays until a human removes it.
820
1014
  if (meta.delivery === "session" && (meta.retained || meta.identity?.mode !== "global")) { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
1015
+ if (meta.identity?.mode === "global" && !meta.retained) globalGrantRetire(meta);
1016
+ for (const joined of joinedTeamsOf(meta)) {
1017
+ try { meta = leaveJoinedTeam(joined.label, meta).meta; }
1018
+ catch (e) { retireWarnings.push(`joined team ${joined.label} cleanup failed: ${e.message || e}`); }
1019
+ }
1020
+ writeProviderTeamsState(meta);
821
1021
  if (meta.retained) {
822
1022
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
823
- out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
1023
+ const retainedWarning = `released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}`;
1024
+ out({ meta: { retired: true, retained: true, identityReleased: true, joinedTeams: joinedTeamsOf(meta), ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: ${[...retireWarnings, retainedWarning].join(" | ")}` });
824
1025
  }
825
- if (meta.identity?.mode === "global") globalGrantRetire(meta);
826
1026
  // No alias means the spawn hook never reported an identity: nothing exists to
827
1027
  // undo, which is completion. An alias WITH no local `.aw` is the opposite —
828
1028
  // the remote record exists and its key is gone, so the self-delete cannot be
@@ -849,28 +1049,31 @@ if (event === "launch") {
849
1049
  // aw 1.36.1 prints the cause as alias_released_reason (workspace.go,
850
1050
  // workspace_self_retire.go); `reason` is tolerated for a later rename.
851
1051
  const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
852
- out({ meta: { retired: true, aliasReusable: released, aliasReason: reason }, ...(released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a different --name (kernels 0.26.0+) or a different --purpose until it is` }) });
1052
+ out({ meta: { retired: true, aliasReusable: released, aliasReason: reason, joinedTeams: joinedTeamsOf(meta) }, ...(retireWarnings.length ? { warning: `oats-aweb: ${retireWarnings.join(" | ")}` } : released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a different --name (kernels 0.26.0+) or a different --purpose until it is` }) });
853
1053
  }
854
1054
  run(["aw", "workspace", "delete", meta.alias], home);
855
1055
  // Honest: the workspace row is deleted, but a hosted local member cannot
856
1056
  // revoke its own AWID certificate (aweb-abim), so the alias is NOT
857
1057
  // reusable. retired stays true because the cleanup is as complete as the
858
1058
  // platform allows; the field and the line carry the truth.
859
- out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a different --name (kernels 0.26.0+) or a different --purpose` });
1059
+ out({ meta: { retired: true, aliasReusable: false, joinedTeams: joinedTeamsOf(meta) }, warning: `oats-aweb: ${[...retireWarnings, `workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a different --name (kernels 0.26.0+) or a different --purpose`].join(" | ")}` });
860
1060
  } catch (e) {
861
1061
  // Exit nonzero: during a required-hook rollback this is the signal that
862
1062
  // compensation did NOT complete, so the spawn is not reported as cleanly
863
1063
  // rolled back while a remote identity still exists.
864
1064
  out({ meta: { retired: false, reason: "self-delete-failed" }, warning: `oats-aweb: self-delete failed (the remote record will linger until stale): ${e.message || e}` }, 1);
865
1065
  }
1066
+ } else if (["teams", "join", "leave"].includes(event)) {
1067
+ try { runTeamsCommand(event); process.exit(0); }
1068
+ catch (e) { console.error(e.code ? `${e.code}: ${e.message}` : `oats aweb ${event}: ${e.message || e}`); process.exit(1); }
866
1069
  } else if (event === "roster") {
867
1070
  // Cross-machine directory: every OATS-spawned instance joins the team with
868
1071
  // alias = instance name, so the team's member roster lists live instances
869
1072
  // wherever they run (plus human members). Local liveness comes from
870
- // `oats status --team`; this is the network view.
1073
+ // `oats status` in the deployment; this is the network view.
871
1074
  const root = awebRoot();
872
1075
  if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
873
- const team = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
1076
+ const team = process.env.OATS_TEAM_ID || JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
874
1077
  if (!team) { console.error(`oats aweb roster: cannot determine team (${teamConfigRemedy()}, or activate a team at the aweb root)`); process.exit(1); }
875
1078
  const teamFlag = team.includes(":") ? ["--team-id", team] : ["--team", team];
876
1079
  const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
@@ -904,11 +1107,8 @@ if (event === "launch") {
904
1107
  const teamName = resolvedTeam.team;
905
1108
  const teamId = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : process.env.OATS_TEAM_ID;
906
1109
  const candidate = rootSettingCandidate(teamName);
907
- const scope = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined
908
- ? resolve(process.env.OATS_TEAM_SCOPE || process.cwd())
909
- : (candidate?.root ? resolve(candidate.root) : process.cwd());
910
- const classic = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined;
911
- console.log(`aweb onboarding — ${classic ? "team scope" : "messaging root"}: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
1110
+ const scope = candidate?.root ? resolve(candidate.root) : process.cwd();
1111
+ console.log(`aweb onboarding — messaging root: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
912
1112
  if (!isAbsolute(scope)) {
913
1113
  console.log(`${candidate?.key || "settings.oats.aweb.root"} must be an absolute directory whose .aw is the aweb minting root.`);
914
1114
  process.exit(0);
@@ -928,7 +1128,7 @@ if (event === "launch") {
928
1128
  console.log(`✓ aweb workspace initialized and member of ${match}.`);
929
1129
  if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
930
1130
  console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
931
- console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
1131
+ console.log(" Roster: `oats aweb roster` · local: `oats status` (in the deployment)");
932
1132
  return;
933
1133
  }
934
1134
  console.log(`readiness: needs-configuration`);
@@ -940,7 +1140,7 @@ if (event === "launch") {
940
1140
  return;
941
1141
  }
942
1142
  console.log(` Workspace initialized, but no membership matching "${want}".`);
943
- if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; set messaging.byTeam.<label>.team or settings.oats.aweb.team to that id, then re-run setup.`);
1143
+ if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; set settings.oats.aweb.team to that id, then re-run setup.`);
944
1144
  console.log(" Existing team path: ask a member for an invite token, then run `oats aweb setup --invite <token>` (uses `aw team join <token>` at the root).");
945
1145
  console.log(" Team API-key path: set AWEB_API_KEY in the environment and run `oats aweb setup` (uses `aw init` at the root; the key is never printed).");
946
1146
  console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --username <u>` and creates default:<u>.aweb.ai).");
@@ -949,7 +1149,7 @@ if (event === "launch") {
949
1149
  try {
950
1150
  const hasRoot = existsSync(join(scope, ".aw"));
951
1151
  if (!hasRoot && !actions.length) {
952
- console.log(`No aweb workspace at the ${classic ? "team scope" : "messaging root"} yet (${candidate?.key || "settings.oats.aweb.root"}).`);
1152
+ console.log(`No aweb workspace at the messaging root yet (${candidate?.key || "settings.oats.aweb.root"}).`);
953
1153
  if (!want) console.log(` Also choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
954
1154
  console.log(" Choose one guided setup path:");
955
1155
  console.log(" oats aweb setup --username <u> # runs `aw init --username <u>` and creates default:<u>.aweb.ai");
@@ -49,9 +49,17 @@ write it to a temp file and use `--body-file` — inline `--body` shell
49
49
  escaping is a recurring failure.
50
50
 
51
51
  Aliases are instance names (e.g. `dev-coordinator-1`). Discovery:
52
- `oats status --team` lists this machine's live instances; `oats aweb roster`
52
+ `oats status` (in the deployment) lists this machine's live instances; `oats aweb roster`
53
53
  lists the aweb team across machines.
54
54
 
55
+ **Joined teams.** Your default identity is the personal team — in 1.14.1 the root's default team as a stand-in until per-workspace personal teams exist. Workspace labels, including the primary label, are wider teams only when explicitly joined. If `oats aweb teams --json`
56
+ shows joined wider teams, each joined entry has an `identityHome`. Send or reply as
57
+ that team with exactly `aw --identity-home <identityHome> mail|chat ...`. In
58
+ 1.14.1 joined teams receive by polling: check `aw --identity-home <identityHome>
59
+ mail inbox` and `aw --identity-home <identityHome> chat pending` at task
60
+ boundaries when you are working through that team. The native channel/wake path
61
+ listens to the primary identity only.
62
+
55
63
  **Notification delivery.** Your instance briefing (TASK.md, the Comms line)
56
64
  says how messages reach you. If it carries "Notification delivery: external",
57
65
  the native channel is NOT running in this session and, until the host wake
@@ -83,10 +83,11 @@ export function parseBindingJson(bytes,limits=BINDING_WIRE_LIMITS) {
83
83
  function settings(value,{phase}={}) {
84
84
  if(!obj(value)) wireError('invalid-binding');
85
85
  if(phase!=='check' && Object.hasOwn(value,'identity')) wireError('provider-not-qualified');
86
- keys(value,phase==='check'?['delivery','team','root','roots','identity','residents']:['delivery','team','root','roots'],[]);
86
+ keys(value,phase==='check'?['delivery','team','root','roots','identity','residents','join']:['delivery','team','root','roots','join'],[]);
87
87
  if(value.delivery!==undefined && !['channel','session'].includes(value.delivery)) wireError('needs-configuration');
88
88
  if(value.team!==undefined && (typeof value.team!=='string' || !value.team.trim())) wireError('needs-configuration');
89
89
  if(value.root!==undefined && (typeof value.root!=='string' || !value.root.trim())) wireError('needs-configuration');
90
+ if(value.join!==undefined && typeof value.join!=='string') wireError('needs-configuration');
90
91
  if(value.roots!==undefined && !obj(value.roots)) wireError('needs-configuration');
91
92
  if(obj(value.roots)) for(const [team,root] of Object.entries(value.roots)) if(!team || typeof root!=='string' || !root.trim()) wireError('needs-configuration');
92
93
  if(value.identity!==undefined && !obj(value.identity)) wireError('needs-configuration');
@@ -164,6 +165,9 @@ function workspaceReadinessContext(value) {
164
165
  }
165
166
  function yamlScalar(text,key){const m=String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`,'m'));return m?m[1].trim():undefined;}
166
167
  export const CUSTODY_ATTACH_MIN = '1.36.3';
168
+ export const WAKE_STREAM_MIN = '1.36.5';
169
+ const CLASSIC_REFUSAL = 'oats.aweb 1.14 needs OATS 0.26.0 or newer (workspace model); on an older kernel pin oats.aweb v1.13.x';
170
+ function classicEnv(env=process.env) {return !!env.OATS_TEAM_SCOPE && !(env.OATS_WORKSPACE_KEY || env.OATS_WORKSPACE_NAME || env.OATS_TEAM_LABEL);}
167
171
  export function grantYamlCustodySocket(text) {
168
172
  const lines=String(text??'').split(/\r?\n/);let inCustody=false,baseIndent=0;
169
173
  for(const line of lines) {
@@ -189,27 +193,35 @@ function grantAttachmentProblem(home) {
189
193
  const id=yamlScalar(text,'grant_id')||'<unknown>';
190
194
  return {code:'custody',message:`grant ${id} is not attached to custody; retire and respawn on aw >= ${CUSTODY_ATTACH_MIN}`};
191
195
  }
192
- function activeTeamAt(root){try{return yamlScalar(readFileSync(join(resolve(root),'.aw','teams.yaml'),'utf8'),'active_team')||yamlScalar(readFileSync(join(resolve(root),'.aw','teams.yaml'),'utf8'),'active');}catch{return undefined;}}
196
+ function activeTeamAt(root){try{const text=readFileSync(join(resolve(root),'.aw','teams.yaml'),'utf8');return yamlScalar(text,'active_team')||yamlScalar(text,'active');}catch{return undefined;}}
197
+ function residentCustodyRoot(settings){const identity=obj(settings.identity)?settings.identity:{},residents=obj(settings.residents)?settings.residents:{};const name=typeof identity.resident==='string'?identity.resident:'';const root=name&&typeof residents[name]==='string'?residents[name]:undefined;return identity.mode==='global'&&root&&isAbsolute(root)?root:undefined;}
193
198
  function teamFromSettings(settings,candidate,{env=process.env}={}) {
194
- const configured=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():(env.OATS_TEAM_ID || env.OATS_TEAM_NAME || undefined);
195
- if(configured || env.OATS_TEAM_LABEL) return configured;
199
+ const configured=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():undefined;
200
+ if(configured) return configured;
201
+ const custody=residentCustodyRoot(settings);if(custody)return activeTeamAt(custody);
196
202
  return candidate?.root && isAbsolute(candidate.root) ? activeTeamAt(candidate.root) : undefined;
197
203
  }
198
- function classicEnv(env=process.env) {return !!env.OATS_TEAM_SCOPE && !(env.OATS_WORKSPACE_KEY || env.OATS_WORKSPACE_NAME || env.OATS_TEAM_LABEL);}
199
204
  function rootCandidate(settings,team,{deployment,env=process.env}={}) {
200
205
  const roots=obj(settings.roots)?settings.roots:{};
201
206
  if(team && typeof roots[team]==='string' && roots[team].trim()) return {root:roots[team].trim(),key:`settings.oats.aweb.roots[${JSON.stringify(team)}]`,declared:true};
202
207
  if(typeof settings.root==='string' && settings.root.trim()) return {root:settings.root.trim(),key:'settings.oats.aweb.root',declared:true};
203
- const candidates=classicEnv(env)?[env.OATS_TEAM_SCOPE,env.OATS_WORKSPACE].filter(Boolean):[env.OATS_WORKSPACE || deployment || env.OATS_TEAM_SCOPE || process.cwd()];
208
+ const candidates=[env.OATS_WORKSPACE || deployment || process.cwd()];
204
209
  for(const root of candidates) if(isAbsolute(root) && existsSync(join(resolve(root),'.aw'))) return {root,key:'settings.oats.aweb.root',declared:false};
205
210
  return {root:candidates[0] || process.cwd(),key:'settings.oats.aweb.root',declared:false};
206
211
  }
212
+ function parseOatsTeams(env=process.env){try{const rows=JSON.parse(env.OATS_TEAMS||'[]');return Array.isArray(rows)?rows.filter(r=>r&&typeof r==='object').map(r=>({label:String(r.label||''),team:typeof r.team==='string'?r.team:null,mapped:r.mapped===true,payload:obj(r.payload)?r.payload:{}})):[];}catch{return [];}}
213
+ function primaryTeamLabel(env=process.env){return env.OATS_TEAM_LABEL || String(env.OATS_TEAM_LABELS||'').split(',').map(s=>s.trim()).filter(Boolean)[0] || null;}
214
+ function unmappedPrimary(env=process.env){const primary=primaryTeamLabel(env);return primary?parseOatsTeams(env).find(t=>t.label===primary&&!t.mapped):undefined;}
215
+ function joinedTeams(home){if(!home)return[];try{const doc=JSON.parse(readFileSync(join(home,'.oats-aweb','teams.json'),'utf8'));return Array.isArray(doc.joinedTeams)?doc.joinedTeams.filter(j=>j&&typeof j==='object'&&j.label&&j.team&&j.identityHome):[];}catch{return[];}}
216
+ function teamsReadiness({home,team,env=process.env}){const teams=parseOatsTeams(env),joined=joinedTeams(home),joinedLabels=new Set(joined.map(j=>j.label));return{personal:{team:team||null},primary:primaryTeamLabel(env),eligible:teams.filter(t=>t.mapped&&t.team).map(t=>({label:t.label,team:t.team,joined:joinedLabels.has(t.label)})),joined:joined.map(j=>({label:j.label,team:j.team,identityHome:j.identityHome,receive:j.receive||'poll',since:j.since})),unmapped:teams.filter(t=>!t.mapped).map(t=>t.label),at:new Date().toISOString()};}
207
217
  function readinessDetails(settings,{deployment,env=process.env}={}) {
208
- const initialTeam=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():(env.OATS_TEAM_ID || env.OATS_TEAM_NAME || undefined);
209
- const candidate=rootCandidate(settings,initialTeam,{deployment,env}),team=teamFromSettings(settings,candidate,{env}),problems=[];
218
+ if(classicEnv(env)) return {team:undefined,candidate:null,warnings:[],result:{status:'needs-configuration',problems:[{code:'needs-configuration',message:CLASSIC_REFUSAL}]}};
219
+ const initialTeam=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():undefined;
220
+ const candidate=rootCandidate(settings,initialTeam,{deployment,env}),team=teamFromSettings(settings,candidate,{env}),problems=[],warnings=[];
210
221
  if(!candidate.root || !isAbsolute(candidate.root) || !existsSync(join(resolve(candidate.root),'.aw'))) problems.push({code:'needs-configuration',message:`no messaging root at ${candidate.root?resolve(candidate.root):process.cwd()}: run oats aweb setup there or set ${candidate.key}`});
211
- if(!team) problems.push({code:'needs-configuration',message:'no team: set messaging.byTeam.<label>.team in the workspace file or settings.oats.aweb.team'});
212
- return {team,candidate,result:checkProblems(problems) || {status:'ready',problems:[]}};
222
+ const unmapped=unmappedPrimary(env);if(unmapped&&team)warnings.push({code:'team-unmapped',message:`workspace label ${unmapped.label} is not mapped; using personal team ${team}`});
223
+ if(!team) problems.push({code:'needs-configuration',message:'no team: set settings.oats.aweb.team or keep an active team at the aweb root'});
224
+ return {team,candidate,warnings,result:checkProblems(problems) || {status:'ready',problems:[]}};
213
225
  }
214
226
  function readinessFromSettings(settings,options) {return readinessDetails(settings,options).result;}
215
227
  function runAw(argv,cwd,{unsetEnv=[],timeout=60000}={}) {
@@ -217,10 +229,25 @@ function runAw(argv,cwd,{unsetEnv=[],timeout=60000}={}) {
217
229
  try {return execFileSync(argv[0],argv.slice(1),{cwd,env,encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout}).trim();}
218
230
  catch(e) {throw new Error(`${argv.slice(0,3).join(' ')} failed${e.status===undefined?'':` (exit ${e.status})`}`);}
219
231
  }
232
+ function semverLt(a,b) {const A=String(a||'0.0.0').split('.').map(n=>Number(n)||0),B=String(b).split('.').map(n=>Number(n)||0);for(let i=0;i<3;i++){if((A[i]||0)!==(B[i]||0)) return (A[i]||0)<(B[i]||0);}return false;}
233
+ function wakeReadiness(home,{reliedOn=false}={}) {
234
+ if(!home || !reliedOn) return {problems:[],warnings:[]};
235
+ try {
236
+ const doc=JSON.parse(runAw(['aw','wake','status','--json'],home,{timeout:10000}));
237
+ const state=doc.daemon_version_state || (doc.daemon_running===false?'not_running':doc.daemon_version?'reported':'unknown');
238
+ if(state==='reported') {
239
+ const running=String(doc.daemon_version||'unknown');
240
+ if(semverLt(running,WAKE_STREAM_MIN)) return {problems:[{code:'wake-daemon-outdated',message:`host wake daemon is running ${running}; required ${WAKE_STREAM_MIN}; upgrade aw, then restart the host wake daemon`}],warnings:[]};
241
+ return {problems:[],warnings:[]};
242
+ }
243
+ if(state==='not_running') return {problems:[{code:'wake-daemon-not-running',message:'host wake daemon is not running; session delivery relies on it'}],warnings:[]};
244
+ return {problems:[],warnings:[{code:'wake-daemon-version-unknown',message:`host wake daemon version is unknown; compatibility unproven; required ${WAKE_STREAM_MIN}; upgrade aw, then restart the host wake daemon`}]};
245
+ } catch {return {problems:[],warnings:[{code:'wake-daemon-version-unknown',message:`host wake daemon version is unknown; compatibility unproven; required ${WAKE_STREAM_MIN}; upgrade aw, then restart the host wake daemon`}]};}
246
+ }
220
247
  function workspaceReadinessPhase(req) {
221
248
  const ctx=workspaceReadinessContext(req.input.context);
222
249
  if(!obj(req.input.action) || req.input.action.kind!=='readiness') wireError('invalid-binding');
223
- const details=readinessDetails(req.settings,{deployment:ctx.deployment}),problems=[...details.result.problems],warnings=[];
250
+ const details=readinessDetails(req.settings,{deployment:ctx.deployment}),problems=[...details.result.problems],warnings=[...details.warnings];
224
251
  const identity=obj(req.settings.identity)?req.settings.identity:{},mode=identity.mode===undefined || identity.mode===null || identity.mode===''?'local':String(identity.mode);
225
252
  if(mode==='global') {
226
253
  const grantProblem=grantAttachmentProblem(ctx.home);if(grantProblem) problems.push(grantProblem);
@@ -237,6 +264,10 @@ function workspaceReadinessPhase(req) {
237
264
  catch(e) {problems.push({code:'custody',message:e.message});}
238
265
  }
239
266
  }
267
+ const teams=process.env.OATS_TEAMS?teamsReadiness({home:ctx.home,team:details.team,env:process.env}):undefined;
268
+ for(const joined of teams?.joined||[]) if(joined.receive==='poll') warnings.push({code:'joined-team-poll-only',message:`joined team ${joined.label} receives by polling in oats.aweb 1.14; check aw --identity-home ${joined.identityHome} mail inbox/chat pending`});
269
+ const wake=String(req.settings.delivery||'channel')==='session'?wakeReadiness(ctx.home,{reliedOn:true}):{problems:[],warnings:[]};
270
+ problems.push(...wake.problems);warnings.push(...wake.warnings);
240
271
  const result=checkProblems(problems) || {status:'ready',problems:[]};
241
272
  return {...result,warnings};
242
273
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.13.1",
4
+ "version": "1.14.2",
5
5
  "compatibility": {
6
- "oats": ">=0.25.6"
6
+ "oats": ">=0.26.0"
7
7
  },
8
8
  "layer": "messaging",
9
9
  "description": "Messaging layer via aweb: per-instance team identities + native aw mail/chat skills + cross-machine team roster.",
@@ -71,7 +71,10 @@
71
71
  "setup": "bin/oats-aweb.mjs setup",
72
72
  "binding-normalize": "bin/oats-aweb-binding.mjs normalize",
73
73
  "binding-bind": "bin/oats-aweb-binding.mjs bind",
74
- "binding-check": "bin/oats-aweb-binding.mjs check"
74
+ "binding-check": "bin/oats-aweb-binding.mjs check",
75
+ "teams": "bin/oats-aweb.mjs teams",
76
+ "join": "bin/oats-aweb.mjs join",
77
+ "leave": "bin/oats-aweb.mjs leave"
75
78
  },
76
79
  "binding": {
77
80
  "version": 1,
@@ -138,7 +141,7 @@
138
141
  "description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
139
142
  },
140
143
  "team": {
141
- "description": "Target aweb team id for identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID/OATS_TEAM_NAME; if both are set and differ the hook warns and uses this setting."
144
+ "description": "Target aweb team id for the primary personal identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID; if both are set and differ the hook warns and uses this setting."
142
145
  },
143
146
  "root": {
144
147
  "hostOnly": true,
@@ -157,9 +160,48 @@
157
160
  "residents": {
158
161
  "hostOnly": true,
159
162
  "description": "Host-owned map for global mode: resident name to absolute custody directory whose .aw holds the resident root keys and team certificate. Put this only in oats-local.yaml settings.oats.aweb.residents; committed workspace or soul files must never carry custody paths."
163
+ },
164
+ "join": {
165
+ "description": "comma-separated eligible team labels to join at spawn; values are workspace-defined labels and the default is absent"
160
166
  }
161
167
  },
162
168
  "environmentNamespaces": [
163
169
  "AWEB_"
164
- ]
170
+ ],
171
+ "operations": {
172
+ "teams": {
173
+ "kind": "action",
174
+ "command": "teams",
175
+ "context": "home",
176
+ "description": "Read-only structured JSON summary of eligible and joined aweb teams for this home."
177
+ },
178
+ "join": {
179
+ "kind": "action",
180
+ "command": "join",
181
+ "context": "home",
182
+ "description": "Join comma-separated eligible aweb team labels for this home.",
183
+ "args": [
184
+ {
185
+ "name": "labels",
186
+ "flag": "--labels",
187
+ "required": true,
188
+ "description": "Comma-separated eligible team labels to join."
189
+ }
190
+ ]
191
+ },
192
+ "leave": {
193
+ "kind": "action",
194
+ "command": "leave",
195
+ "context": "home",
196
+ "description": "Leave comma-separated joined aweb team labels for this home.",
197
+ "args": [
198
+ {
199
+ "name": "labels",
200
+ "flag": "--labels",
201
+ "required": true,
202
+ "description": "Comma-separated joined team labels to leave."
203
+ }
204
+ ]
205
+ }
206
+ }
165
207
  }
@@ -1,328 +1,106 @@
1
1
  ---
2
2
  name: aweb-team-membership
3
- description: This skill should be used when joining or being added to an aweb team, picking the correct invite/add-member path for the team's authority model (hosted vs BYOT), accepting invites, fetching team certificates, switching the active team across multiple memberships, distinguishing hosted from Bring Your Own Team (BYOT) authority, running the fresh BYOT setup into aweb cloud, or diagnosing team-certificate and active-team failures. Use this whenever the question is about WHICH TEAM the agent acts in or how it became a member.
4
- allowed-tools: "Bash(aw *)"
3
+ description: This skill should be used when reasoning about which aweb/OATS teams an agent belongs to, checking team certificates and active-team diagnostics, or using the OATS provider's team operations (`oats aweb teams|join|leave`). Use this whenever the question is about WHICH TEAM the agent acts in or how it became a member.
4
+ allowed-tools: "Bash(aw workspace status), Bash(aw team list), Bash(aw id cert show), Bash(oats aweb *)"
5
5
  ---
6
6
 
7
- # aweb Team Membership
7
+ # aweb Team Membership for OATS agents
8
8
 
9
- Use this skill when the question is about teams — joining, leaving, switching between, or troubleshooting team certificates. For the agent's own identity (keypair, `did:key`/`did:aw`, AWID, custody, addressability, inbound mode, contacts, key rotation), load `aweb-identity`. For day-to-day work coordination, load `aweb-coordination`. For mail/chat policy, load `aweb-messaging`. For creating a new team from a template, load `aweb-bootstrap`.
9
+ Use this skill when the question is about teams: current membership, eligible
10
+ workspace teams, joined wider teams, team certificates, or why a message/command
11
+ is landing in the wrong team. For identity keys, `did:key`/`did:aw`, custody,
12
+ addressability, inbound mode, contacts, or key rotation, load `aweb-identity`.
13
+ For mail/chat policy, load `aweb-messaging`.
10
14
 
11
- ## Foundations
15
+ ## OATS owns agent team changes
12
16
 
13
- This skill builds on the identity vocabulary in `aweb-identity` (keypair, `did:key`, `did:aw`, AWID, custodial vs self-custodial). Read that section first if any of those terms are unfamiliar. Team-specific additions:
17
+ For an OATS-managed instance, do **not** manually run native `aw team` mutation
18
+ commands to join, switch, invite, or leave teams. The `oats.aweb` provider owns
19
+ those lifecycle effects so it can keep per-team identity homes, provider state,
20
+ retire cleanup, readiness, and Desktop operations consistent.
14
21
 
15
- - **Team controller** — a keypair separate from member identities. Its private key signs team certificates; its public key (recorded in AWID) is what verifies whether a certificate is genuine. Team controllers authorize membership; they do not decrypt member conversations and must not substitute a member's E2E encryption key.
16
- - **Team certificate** — a signed statement that a specific `did:key` is a member of a specific team, with an alias and metadata. Public; replicated in AWID. Stored locally in `.aw/team-certs/*.pem`. A certificate proves team membership; it is not a message-decryption key.
17
- - **Team id** — canonical form is `<name>:<namespace>` (e.g. `personal:acme.com`, `aweb:juan.aweb.ai`). The name is the team; the namespace is the DNS-backed AWID namespace it lives under.
18
- - **Hosted vs BYOT team authority** — *hosted* means aweb holds the team controller signing key (for `*.aweb.ai` namespaces). *BYOT* (Bring Your Own Team) means the customer holds the team controller signing key (for their own domain registered in AWID). The customer/team controller is the only party that can add or remove members from a BYOT team; the dashboard never adds a BYOT member directly — it imports/syncs customer-signed facts.
19
-
20
- ## Team-related files in `.aw/`
21
-
22
- For identity files (`signing.key`, `workspace.yaml` server URL), see `aweb-identity`. Team-specific files:
23
-
24
- - `teams.yaml` — local index of teams this identity is a member of. Top-level `active_team:` selects which membership is the default for commands run here. `aw team list` reads this; `aw team switch <team-id>` updates it.
25
- - `team-certs/*.pem` — public team certificates this identity has been issued (one `.pem` per team membership). `teams.yaml` and `workspace.yaml` reference these by `cert_path`.
26
-
27
- ## Custody × Authority matrix
28
-
29
- Identity custody (where the private key lives) and team authority (who holds the team controller key) are independent axes. Use the matrix to pick the right joining path:
30
-
31
- | Team authority | Identity custody | Meaning |
32
- | --- | --- | --- |
33
- | Hosted | Custodial | aweb manages team authority AND holds hosted identity signing key material (browser/MCP). Messaging in this mode is server-readable hosted messaging, not E2E. |
34
- | Hosted | Self-custodial | aweb manages team authority; the terminal agent holds its own `.aw/signing.key`. |
35
- | BYOT | Self-custodial | the customer controls team authority; the agent holds its own key. |
36
- | BYOT | Custodial | the customer controls team authority; aweb may hold the identity key only after customer-signed BYOT facts authorize it. |
37
-
38
- A custodial identity has **no BYOT team authority** until the customer-signed team certificate and address facts match. Do not infer team authority from identity custody.
39
-
40
- For E2E messaging, custody and team membership are still not enough by themselves: the recipient's encryption public key must be identity-authorized as described in `docs/e2e-messaging-contract.md`. Team/namespace authority may distribute that assertion, but it must not replace the member's key. If an encryption-key check fails, do not suggest a team-controller workaround or plaintext fallback; stop and route the user to the approved identity/key setup or recovery flow.
41
-
42
- ## Readiness checks (membership level)
43
-
44
- Start with:
45
-
46
- ```bash
47
- aw workspace status
48
- aw team list
49
- aw id cert show
50
- ```
51
-
52
- Interpret failures by what's missing (for self-custodial CLI workspaces; custodial browser/MCP identities live entirely in the hosted account):
53
-
54
- - **`.aw/teams.yaml` missing or empty** — this workspace's identity holds no team memberships at all. Join one (see paths below) before attempting team coordination.
55
- - **No `.aw/team-certs/<team>.pem` for `teams.yaml`'s active team** — identity exists but holds no cert for the active team. Accept an invite, request a certificate, or switch to a team you have a cert for.
56
- - **Active team mismatch** — `teams.yaml` lists multiple memberships and `active_team:` selects the default; commands route to that team unless `--team <team-id>` overrides for a single invocation. If commands appear to land in the wrong team, fix `active_team:` (run `aw team switch <team-id>`).
57
- - **Workspace not bound to a server** — `workspace.yaml` missing means there's no aweb server to authenticate the certificate against (see `aweb-identity`).
58
-
59
- ## Joining a team — match the path to team authority
60
-
61
- The right joining path depends entirely on **who holds the team controller signing key**. Pick by authority, not by the word "invite" alone.
62
-
63
- ### Hosted teams (aweb holds the team controller key)
64
-
65
- Three distinct paths exist; they are NOT interchangeable.
66
-
67
- **Path 1 — Fresh identity at init time.** A new agent without any prior identity arrives at a hosted team via OAuth (browser/MCP) or team API-key (CLI):
68
-
69
- ```bash
70
- AWEB_API_KEY=<team-api-key> aw init
71
- ```
72
-
73
- The hosted service provisions the identity and a team certificate together. Browser/MCP harnesses do this through OAuth without a CLI. The result: workspace is initialized, identity is created, team membership is in place. Verify with `aw workspace status` and `aw team list`.
74
-
75
- **Path 2 — Existing global identity → "Add existing identity" in the dashboard.** When an agent already has a `did:aw` registered in AWID and wants to join an existing hosted team:
76
-
77
- In the app.aweb.ai dashboard, an owner/admin clicks "Add existing identity" on the team, supplies the global identity's address or `did:aw`, and the backend mints a team certificate with the cloud-held team controller key, registers it in AWID, and prints commands like:
78
-
79
- ```bash
80
- aw id team fetch-cert --namespace <namespace> --team <team> --cert-id <cert-id>
81
- aw team switch <team>:<namespace>
82
- aw init # if the joining directory still needs server binding
83
- ```
84
-
85
- No token round-trip. Direct controller-mint. Available only for hosted teams because BYOT controller keys aren't held by aweb.
86
-
87
- **Path 3 — CLI invite-token, for hosted self-custodial local or global identities.** Hosted CLI invite tokens are redeemed through the cloud, but the accepting directory keeps its own signing key. Use the default form when the owner wants to invite a new local-workspace identity by token:
88
-
89
- ```bash
90
- # Owner side (in a workspace with the necessary authority):
91
- aw team invite # local-workspace member token
92
-
93
- # share the printed <token>
94
-
95
- # Joiner side (in a clean target directory):
96
- aw team join <token> --name <name>
97
- aw init # finish wiring the new workspace if instructed
98
- ```
99
-
100
- `accept-invite` refuses to overwrite an existing `.aw/` identity and generates a fresh local self-custodial identity in the target directory before requesting the certificate. For a hosted global identity, accept the hosted token with `--address <domain>/<name>`:
101
-
102
- ```bash
103
- aw team join <token> --address <domain>/<name>
104
- aw init # finish wiring the new workspace if instructed
105
- ```
106
-
107
- In the hosted `--address` case, the CLI creates a fresh self-custodial global identity for the address, registers it through the hosted service, and installs the hosted team certificate. The hosted service signs the team certificate; it does not receive the accepting directory's private signing key. If the user already has a global identity and only needs a certificate for an existing hosted team, Path 2 (dashboard Add existing identity + `fetch-cert`) remains valid.
108
-
109
- This is **not** the cross-machine BYOT path. Do not present it as the normal way to join a BYOT team from another machine.
110
-
111
- **Do not run `aw id team add-member` for a hosted team** — `add-member` signs a certificate with the team controller key, which you do not hold for hosted teams. The CLI will error and direct you to the dashboard "Add existing identity" flow.
112
-
113
- ### BYOT teams (customer holds the team controller key)
114
-
115
- The dashboard cannot add BYOT members directly. The customer's team controller must sign. Three cases:
116
-
117
- **Case 1 — Self-custodial identity, cross-machine join.** The joining machine doesn't hold the team controller key:
118
-
119
- ```bash
120
- # Joining identity machine
121
- aw id team request --team <team>:<namespace> --name <name>
122
-
123
- # Controller machine — runs the exact command the request printed:
124
- aw id team add-member ...
125
-
126
- # Back on the joining identity machine
127
- aw id team fetch-cert --namespace <namespace> --team <team> --cert-id <id>
128
- aw team switch <team>:<namespace>
129
- aw init # if needed
130
- ```
131
-
132
- The team controller private key never leaves the controller machine.
133
-
134
- **Case 2 — Custodial browser identity into a BYOT team.** Start from the dashboard's "Create custodial request" action. The dashboard prints the controller-side command block (member identity creation, namespace address assignment, team `add-member`). The team controller runs that block on their machine, then syncs the signed team state into aweb cloud:
135
-
136
- ```bash
137
- aw id team import-request --team <team> --namespace <namespace> --cloud-team-id <cloud-team-id> --apply
138
- ```
139
-
140
- aweb cloud projects the customer-signed facts; it does not mint anything itself. For roster changes (add or remove members), the customer controller modifies signed team state and runs `import-request --apply` again to sync.
141
-
142
- **Case 3 — Same-machine local-controller invite-token convenience.** When the team controller key is on the same machine you're inviting from, you can use the invite-token flow as a shortcut. Two variants:
143
-
144
- ```bash
145
- # Owner side, same machine, local-controller key present:
146
- aw team invite # local-workspace member (default)
147
- aw team invite --global # global-member token (requires existing global identity in the accepting directory)
148
-
149
- # Joiner side (still same machine, different directory):
150
-
151
- # For a local invite:
152
- aw team join <token> --name <name>
153
-
154
- # For a global invite, the accepting directory must already have a global identity:
155
- aw id create --domain <domain> --name <name>
156
- aw team join <token> --address <namespace>/<name>
157
- ```
158
-
159
- For the `--global` case, `accept-invite` does NOT create the global identity — it errors with `no identity found; run aw id create first, or use --local invite` if no identity is present. `--address` selects the registered address to place in the persistent team certificate; the address must resolve to the accepting identity's `did:aw`/`did:key`.
160
-
161
- This is the local-controller convenience case only. For cross-machine BYOT joins, use Case 1.
162
-
163
- ### Not a membership path: human dashboard invites
164
-
165
- `/api/v1/teams/.../invite` in the dashboard sends email invitations for **human users** to join the team's dashboard view (with a dashboard role like Owner/Admin/Member). That is independent of AWID agent membership certificates. Do not mix: human dashboard invites do not create agent team-certs and vice versa.
166
-
167
- ## Accepting an invite vs fetching a certificate
168
-
169
- Two distinct local actions install a membership:
170
-
171
- - **`aw team join <token>`** (human-facing alias for `aw id team accept-invite <token>`) — redeems a CLI invite token. For hosted invites (Path 3), generates a fresh self-custodial identity in the current directory (refusing to overwrite) and installs the certificate; default is local, while `--address <domain>/<name>` creates/registers a fresh global identity through the hosted service before certificate install. For local-controller same-machine invites (BYOT Case 3), local-member behaves the same way as hosted local; global accepts require an existing global identity (from `aw id create`) and attach a team certificate to it via `--address <namespace>/<name>`.
172
- - **`aw id team fetch-cert --namespace <namespace> --team <team> --cert-id <id>`** — installs a certificate that has already been minted server-side (by hosted "Add existing identity") or signed by a controller (BYOT `add-member`). Used for hosted Path 2 and BYOT Case 1.
173
-
174
- If you have a token, use `aw team join` (or the underlying primitive `aw id team accept-invite`). If you have a `cert-id` printed by the dashboard or controller, use `fetch-cert`.
175
-
176
- ## Multiple team memberships
177
-
178
- One identity can hold multiple team certificates simultaneously — one per team — all stored in `.aw/team-certs/`. Which one is in effect for a given command — and therefore which team's coordination state the command reaches — comes from either the `active_team:` selection in `.aw/teams.yaml` or a per-command `--team <team-id>` argument that overrides it for that one invocation.
179
-
180
- ```bash
181
- aw team list # see memberships
182
- aw team switch <team>:<namespace> # update teams.yaml's active_team
183
- aw <verb> --team <team>:<namespace> ... # one-off override for team-scoped commands
184
- aw team leave <team>:<namespace> # remove a local membership
185
- ```
186
-
187
- Acting in the wrong active team can send messages, claims, or locks to the wrong coordination boundary. Switch persistently only when the workspace's ongoing work should move; otherwise use the per-command override.
188
-
189
- If the recipient's `inbound_mode` is `team-and-contacts`, valid same-team membership is one of the authorization paths for delivery to them. The full inbound-mode model is in `aweb-identity`.
190
-
191
- ## Fresh BYOT setup into aweb cloud
192
-
193
- Use this flow when the user controls DNS for a domain and wants to create a customer-controlled AWID team, add agents, and import/sync it into app.aweb.ai.
194
-
195
- Vocabulary:
196
-
197
- - The namespace is the domain, e.g. `juanreyero.com`.
198
- - The team is named inside that namespace, e.g. `personal`; its AWID team id is `personal:juanreyero.com`.
199
- - Agents have addresses under the namespace, e.g. `juanreyero.com/alpha`.
200
- - Do not call `personal:juanreyero.com` an agent; it is the team id.
201
-
202
- Before starting, confirm `aw version` includes `aw id namespace prepare-controller` and `aw id namespace check-txt`; older `aw` versions make this flow hard to drive from non-interactive harnesses.
203
-
204
- **Use `aw id create`, NOT `aw init --byod --global`, for the identity-prep commands in this section.** `aw init --byod --global` is workspace onboarding: it bootstraps the directory and connects it to the `default:<domain>` team on app.aweb.ai (the team created during BYOD onboarding), writing `workspace.yaml`, joining that team, and minting a team certificate. That short-circuits the controller-signed team-state import this section is about. `aw id create` only mints the identity in AWID and writes `.aw/identity.yaml` + `.aw/signing.key`, leaving the team membership for the customer controller to add and sign. If a user already ran the wrong command and needs to recover, see the matching diagnostic bullet in `aweb-identity`'s readiness checks.
205
-
206
- Namespace controller setup (does not create an identity or team):
207
-
208
- ```bash
209
- aw id namespace prepare-controller --domain <domain>
210
- ```
211
-
212
- Pause and have the human add the printed `_awid.<domain>` TXT record. Do not invent DNS values. Tell the human to back up `~/.awid` now; it contains the namespace controller key. After DNS propagates, verify it:
213
-
214
- ```bash
215
- aw id namespace check-txt --domain <domain>
216
- ```
217
-
218
- Then create the BYOT team with the namespace controller key:
219
-
220
- ```bash
221
- aw id team create --namespace <domain> --name <team> --display-name "<display name>"
222
- ```
223
-
224
- After team creation, tell the human to back up `~/.awid` again; it now also contains the team controller key under `~/.awid/team-keys/<domain>/<team>.key`.
225
-
226
- Add initial global agents:
227
-
228
- ```bash
229
- aw id create --domain <domain> --name alpha
230
- aw id team add-member --team <team> --namespace <domain> --did <alpha_did_key> --name alpha --global --did-aw <alpha_did_aw>
231
-
232
- aw id create --domain <domain> --name beta
233
- aw id team add-member --team <team> --namespace <domain> --did <beta_did_key> --name beta --global --did-aw <beta_did_aw>
234
- ```
235
-
236
- Use the actual `did`/`did_aw` values printed by `aw id create`. Do not guess them.
237
-
238
- Register with aweb cloud without using the dashboard:
239
-
240
- ```bash
241
- aw id team register --service https://app.aweb.ai --team <team>:<domain>
242
- ```
243
-
244
- This signs a service-registration request with the team controller key. It creates or syncs an aweb projection of the AWID team, but it does not upload controller private keys, create identities, or initialize any agent workspace. Read the returned next steps and run the required workspace connection command from each already-certified agent directory:
245
-
246
- ```bash
247
- aw workspace connect --service https://app.aweb.ai --team <team>:<domain>
248
- # equivalent service-oriented primitive: aw service init --service https://app.aweb.ai --team <team>:<domain>
249
- ```
250
-
251
- `aw workspace connect` / `aw service init` requires the local `.aw/signing.key`, `.aw/teams.yaml`, and `.aw/team-certs/*.pem` for that agent. If the certificate is not installed yet, fetch it first with the certificate id returned by `aw id team add-member`:
22
+ Use the provider commands from the instance home (or with `--home <path>`):
252
23
 
253
24
  ```bash
254
- aw id team fetch-cert --namespace <domain> --team <team> --cert-id <cert-id>
25
+ oats aweb teams --json # personal, eligible, joined, unmapped
26
+ oats aweb join --labels <label>[,<label>] # join eligible workspace labels
27
+ oats aweb leave --labels <label>[,<label>] # leave joined wider-team labels
255
28
  ```
256
29
 
257
- After at least one workspace is initialized, the service may suggest a human-claim command such as:
30
+ - The personal team cannot be left; attempting it is `E_TEAM_PERSONAL`.
31
+ - A label that is not eligible for this soul/workspace is `E_TEAM_NOT_ELIGIBLE`.
32
+ - Joined wider teams use a local identity home such as
33
+ `<home>/.aweb-identity-<label>` and receive by polling in oats.aweb 1.14. Joined teams require aw >= 1.36.12. The provider creates joined homes with `aw id team accept-invite` under `--identity-home`, verifies the root auto-connected, and does not run `aw init` inside the per-team home.
34
+ - Send as a joined team with exactly:
258
35
 
259
36
  ```bash
260
- aw claim-human --email you@example.com
37
+ aw --identity-home <identityHome> mail|chat ...
261
38
  ```
262
39
 
263
- `claim-human` is a service-account/human-login step for billing and dashboard ownership. It is not AWID team-controller authority and it does not add members to the BYOT team.
40
+ `oats aweb teams --json` prints each joined entry's `identityHome` and
41
+ `receive` mode.
264
42
 
265
- Import into an existing aweb organization:
43
+ ## Readiness checks
266
44
 
267
- 1. In app.aweb.ai, create or select the owner organization that should contain the imported team.
268
- 2. Open the BYOT import flow. Prefer the command shown by the dashboard because it contains the correct `--organization-id`.
269
- 3. First preview:
45
+ Start with read-only diagnostics:
270
46
 
271
47
  ```bash
272
- aw id team import-request --team <team> --namespace <domain> --organization-id <org-id>
273
- ```
274
-
275
- Paste the signed output and use Preview.
276
-
277
- 4. If the preview is correct, regenerate an apply request:
278
-
279
- ```bash
280
- aw id team import-request --team <team> --namespace <domain> --organization-id <org-id> --apply
281
- ```
282
-
283
- Paste it and use Import / sync.
284
-
285
- Sync later changes:
286
-
287
- - After the team exists in aweb cloud, use `--cloud-team-id <cloud-team-id>` instead of `--organization-id`.
288
- - The dashboard Connect / Sync page should show the exact command. Prefer that command.
289
-
290
- ```bash
291
- aw id team import-request --team <team> --namespace <domain> --cloud-team-id <cloud-team-id> --apply
48
+ aw workspace status
49
+ oats aweb teams --json
50
+ aw team list
51
+ aw id cert show
292
52
  ```
293
53
 
294
- ## Diagnostic recipes
295
-
296
- ### "I am in two teams; what does that entail?"
297
-
298
- Treat teams as separate coordination boundaries for tasks, locks, roles, instructions, presence, and same-team aliases. Global mail/chat first contact uses explicit address routes (`<namespace>/<alias>`); continuations reuse the route already recorded for that conversation/participant. Confirm `active_team:` in `teams.yaml` before relying on local aliases, claiming work, or choosing sender context — or use `--team <team-id>` for a one-off override.
299
-
300
- ### "Team cert or active team mismatch"
301
-
302
- Inspect `.aw/teams.yaml` and `.aw/team-certs/`. Confirm:
303
- - a `.pem` file exists for each team you expect to be a member of;
304
- - `teams.yaml`'s `active_team:` matches the team you actually want commands to land in;
305
- - `aw team list` agrees with both.
306
-
307
- Switch with `aw team switch <team-id>`, or reinitialize only after confirming with the team owner/coordinator.
308
-
309
- ### "X says they cannot reach me"
310
-
311
- First check the route + inbound mode on the recipient side — full model in `aweb-identity`. Then, for `team-and-contacts` recipients, check shared team membership:
312
-
313
- 1. `aw team list` on both sides — do you have a certificate in a common team?
314
- 2. `.aw/team-certs/` — is the certificate present for that team?
315
- 3. Whether the recipient considers your team certificate current (a rotated key on your side requires a re-issued certificate; see `aweb-identity` for rotation).
316
-
317
- ### "I joined a team but commands hit the wrong one"
318
-
319
- The new membership added a `.pem` to `team-certs/` and a row in `teams.yaml`, but `active_team:` may not have updated. Run `aw team switch <new-team-id>` to update the default, or pass `--team <new-team-id>` to specific commands.
54
+ Interpret common states:
55
+
56
+ - `teams.personal.team` is the primary personal team identity wired to the
57
+ harness.
58
+ - `eligible[]` are labels this soul/workspace may explicitly join; the primary
59
+ label may appear here and is joinable/leavable like any other wider team.
60
+ - `joined[]` are provider-created wider-team memberships; each has an
61
+ `identityHome`, `since`, and `receive` (`poll` in 1.14).
62
+ - `unmapped[]` labels are present on the soul but not mapped by the workspace.
63
+ An unmapped primary falls back to the personal/root active team with a
64
+ `team-unmapped` warning; it is not a spawn blocker.
65
+ - `teams-unverified` on launch means the kernel supplied recorded/unknown team
66
+ data, so the provider kept memberships instead of leaving anything.
67
+
68
+ ## Team vocabulary
69
+
70
+ - **Team id**: canonical form `<name>:<namespace>` (for example
71
+ `default:oats.aweb.ai`).
72
+ - **Team certificate**: a signed membership statement for an identity; stored in
73
+ `.aw/team-certs/` for native identities.
74
+ - **Personal team**: the default team for the instance's primary identity. In
75
+ 1.14.1, until per-workspace personal teams are available, this may be the
76
+ person's default team as a stand-in.
77
+ - **Joined team**: an explicit wider team joined through `oats aweb join`, with a
78
+ separate local identity home in this release.
79
+
80
+ ## Hosted vs BYOT authority (diagnostic context)
81
+
82
+ Hosted teams are signed by aweb-held team authority; BYOT teams are signed by a
83
+ customer-held controller. This matters when diagnosing why a human or provider
84
+ cannot mint a certificate, but ordinary OATS agents should still use
85
+ `oats aweb join|leave` rather than native membership mutation commands. If a
86
+ join reports authorization failure, ask the team's owner/admin for the needed
87
+ invite or mapping; do not invent a native workaround.
88
+
89
+ ## Wrong team symptoms
90
+
91
+ If commands appear to use the wrong team:
92
+
93
+ 1. Run `oats aweb teams --json` and confirm which identity home should send.
94
+ 2. For the primary identity, run `aw workspace status` and `aw team list`.
95
+ 3. For a joined team, run `aw --identity-home <identityHome> mail inbox` or
96
+ `aw --identity-home <identityHome> chat pending` and send with the same
97
+ `--identity-home`.
98
+ 4. If the provider state and native files disagree, report the exact output to a
99
+ coordinator; do not hand-edit `.aw` or `.oats-aweb/teams.json`.
320
100
 
321
101
  ## References
322
102
 
323
- Read these only when deeper context is needed:
103
+ Read only when deeper context is needed:
324
104
 
325
- - `references/team-membership-reference.md`: detailed hosted/BYOT and diagnostic notes.
326
105
  - <https://aweb.ai/docs/teams/>: team model.
327
- - <https://github.com/awebai/aweb/blob/main/docs/byot-onboarding-contract.md>: fully hosted vs BYOT contract.
328
- - <https://aweb.ai/docs/agent-guide/>: full agent guide.
106
+ - <https://aweb.ai/docs/agent-guide/>: agent messaging guide.
package/docs/packages.md CHANGED
@@ -74,7 +74,7 @@ members:
74
74
  packages:
75
75
  oats.framework: v1.1.3
76
76
  oats.okf: v2.1.5
77
- oats.aweb: v1.13.1
77
+ oats.aweb: v1.14.2
78
78
  teams:
79
79
  global: { description: Org-wide }
80
80
  engineering: { description: Platform }
@@ -0,0 +1,29 @@
1
+ # OATS 0.27.1
2
+
3
+ ## Changed
4
+
5
+ - **oats.aweb 1.14.2 is bundled and pinned** (was 1.13.1): **joining teams
6
+ beyond the personal one now works.** An instance joins the eligible teams it
7
+ is given at spawn (`--provider oats.aweb join=<labels>`) or later with
8
+ `oats aweb join <labels>` / the Desktop's Teams controls, and leaves with
9
+ `oats aweb leave <labels>`. Each joined team gets its own identity under the
10
+ instance home. Joined teams **poll** (their mail is read between tasks); live
11
+ receive for joined teams is planned for oats.aweb 1.15. Joining needs
12
+ **aw 1.36.12 or later** (older aw answers `E_TEAM_AW_FLOOR`; the primary
13
+ identity still mints). A leave removes the local identity only after aweb
14
+ confirms the membership is released, so a failed leave can be retried.
15
+ Retire leaves every joined team before releasing the primary identity.
16
+ - The framework workspace (`oats-workspace.yaml`) pins oats.aweb v1.14.2.
17
+ - Skip oats.aweb 1.14.0 and 1.14.1 (tagged, never pinned by an OATS release): their binding check answered a `teams` key that OATS rejects, so `oats readiness` showed messaging unknown; 1.14.2 fixes it, and 1.14.0's joins were refused by aw.
18
+
19
+ ## Known limitations
20
+
21
+ - The bundled oats.okf is still 2.1.5: its harvest worker spawns with `--runtime`, so each harvest answers one `deprecated-runtime-name` warning on 0.27.x. It's harmless; oats.okf 2.1.6 passes `--harness`.
22
+
23
+ - A per-workspace personal team still needs oats.aweb 1.15 (the aweb service
24
+ and CLI already carry personal enrollment); until then "personal" is the
25
+ person's active aweb team.
26
+
27
+ ## Upgrading from 0.27.0
28
+
29
+ - To join teams: upgrade aw to 1.36.12 or later (and restart the host's wake daemon), then `oats sync` so the deployment's lock takes oats.aweb 1.14.2. Existing instances keep their primary identity; they join teams with `oats aweb join <labels>`.
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.13.1",
11
+ "ref": "v1.14.2",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",