@awebai/oats 0.29.4 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +12 -6
  2. package/bin/oats.mjs +194 -50
  3. package/capabilities/oats-aweb/bin/oats-aweb.mjs +538 -204
  4. package/capabilities/oats-aweb/injects/aweb.md +1 -1
  5. package/capabilities/oats-aweb/lib/binding-wire.mjs +31 -22
  6. package/capabilities/oats-aweb/oats.json +5 -12
  7. package/capabilities/oats-aweb/skills/VENDORED.md +4 -4
  8. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +1 -1
  9. package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +83 -13
  10. package/capabilities/oats-code-review/injects/reviewer.md +26 -0
  11. package/capabilities/oats-code-review/oats.json +16 -0
  12. package/capabilities/oats-code-review/skills/adversarial-review/SKILL.md +66 -0
  13. package/capabilities/oats-code-review/skills/review-dev-docs/SKILL.md +30 -0
  14. package/capabilities/oats-code-review/skills/security-review/SKILL.md +56 -0
  15. package/capabilities/oats-code-review/skills/simplification-review/SKILL.md +34 -0
  16. package/capabilities/oats-developer/injects/developer.md +38 -0
  17. package/capabilities/oats-developer/oats.json +17 -0
  18. package/capabilities/oats-developer/skills/execution-strategy/SKILL.md +43 -0
  19. package/capabilities/oats-developer/skills/maintain-dev-docs/SKILL.md +47 -0
  20. package/capabilities/oats-developer/skills/run-the-review-loop/SKILL.md +65 -0
  21. package/capabilities/oats-developer/skills/understand-the-spec/SKILL.md +37 -0
  22. package/capabilities/oats-developer/skills/worktrees/SKILL.md +36 -0
  23. package/capabilities/oats-engineering-expert/injects/expert.md +37 -0
  24. package/capabilities/oats-engineering-expert/oats.json +17 -0
  25. package/capabilities/oats-engineering-expert/skills/coordinate-developers/SKILL.md +37 -0
  26. package/capabilities/oats-engineering-expert/skills/coordinate-experts/SKILL.md +52 -0
  27. package/capabilities/oats-engineering-expert/skills/land-your-prs/SKILL.md +50 -0
  28. package/capabilities/oats-engineering-expert/skills/plan-and-spec/SKILL.md +53 -0
  29. package/capabilities/oats-engineering-expert/skills/verify-developer-work/SKILL.md +49 -0
  30. package/capabilities/oats-okf/bin/oats-okf.mjs +8 -4
  31. package/capabilities/oats-okf/lib/binding-wire.mjs +47 -15
  32. package/capabilities/oats-okf/lib/inspection.mjs +26 -7
  33. package/capabilities/oats-okf/lib/sources.mjs +16 -2
  34. package/capabilities/oats-okf/lib/worker.mjs +5 -16
  35. package/capabilities/oats-okf/oats.json +6 -3
  36. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +2 -2
  37. package/capabilities/oats-okf-harvest/oats.json +3 -3
  38. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +6 -6
  39. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +2 -2
  40. package/capabilities/oats-okf-maintenance/injects/maintainer.md +1 -1
  41. package/capabilities/oats-okf-maintenance/oats.json +2 -2
  42. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +1 -1
  43. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +11 -23
  44. package/capabilities/oats-workspace-experts/injects/oats-experts.md +26 -0
  45. package/capabilities/oats-workspace-experts/oats.json +9 -0
  46. package/docs/capabilities.md +160 -171
  47. package/docs/capability-manifest.schema.json +6 -11
  48. package/docs/configuration.md +213 -64
  49. package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
  50. package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
  51. package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
  52. package/docs/design/2026-09-27-team-model-v2.md +97 -117
  53. package/docs/design/2026-09-28-automations-trust.md +38 -0
  54. package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
  55. package/docs/design/HISTORY.md +65 -0
  56. package/docs/design/README.md +23 -54
  57. package/docs/desktop-cli-api.md +1787 -1777
  58. package/docs/desktop.md +30 -91
  59. package/docs/execution-targets.md +146 -292
  60. package/docs/first-team.md +31 -17
  61. package/docs/implementation.md +76 -288
  62. package/docs/integrations.md +118 -320
  63. package/docs/knowledge-capability-authoring.md +25 -52
  64. package/docs/knowledge-reference/acceptance.md +3 -3
  65. package/docs/knowledge-reference/adoption.md +1 -1
  66. package/docs/knowledge-reference/harvester.md +2 -2
  67. package/docs/knowledge-reference/package-craft.md +3 -3
  68. package/docs/knowledge-reference/provider-mapping.md +3 -6
  69. package/docs/knowledge-reference/reader-capture.md +3 -3
  70. package/docs/knowledge-theory.md +62 -166
  71. package/docs/knowledge.md +225 -404
  72. package/docs/layers.md +42 -97
  73. package/docs/oats-local.schema.json +58 -5
  74. package/docs/oats-membership.schema.json +1 -8
  75. package/docs/oats-package.schema.json +5 -5
  76. package/docs/oats-workspace.schema.json +8 -22
  77. package/docs/official-catalog.md +25 -28
  78. package/docs/packages.md +45 -63
  79. package/docs/plans/0.30-close-out.md +61 -0
  80. package/docs/release-lane.md +77 -0
  81. package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
  82. package/docs/release-notes/v0.19.0.md +48 -147
  83. package/docs/release-notes/v0.19.1.md +2 -3
  84. package/docs/release-notes/v0.19.3.md +2 -15
  85. package/docs/release-notes/v0.20.0.md +0 -15
  86. package/docs/release-notes/v0.22.0.md +71 -138
  87. package/docs/release-notes/v0.22.1.md +42 -90
  88. package/docs/release-notes/v0.22.10.md +1 -1
  89. package/docs/release-notes/v0.22.11.md +1 -47
  90. package/docs/release-notes/v0.22.12.md +4 -13
  91. package/docs/release-notes/v0.22.13.md +1 -42
  92. package/docs/release-notes/v0.22.14.md +3 -11
  93. package/docs/release-notes/v0.22.15.md +1 -46
  94. package/docs/release-notes/v0.22.16.md +6 -8
  95. package/docs/release-notes/v0.22.18.md +1 -99
  96. package/docs/release-notes/v0.22.19.md +3 -14
  97. package/docs/release-notes/v0.22.2.md +6 -15
  98. package/docs/release-notes/v0.22.3.md +0 -1
  99. package/docs/release-notes/v0.22.4.md +1 -14
  100. package/docs/release-notes/v0.22.5.md +2 -12
  101. package/docs/release-notes/v0.22.6.md +0 -3
  102. package/docs/release-notes/v0.23.0.md +9 -25
  103. package/docs/release-notes/v0.23.1.md +9 -25
  104. package/docs/release-notes/v0.23.2.md +2 -4
  105. package/docs/release-notes/v0.24.0.md +56 -97
  106. package/docs/release-notes/v0.24.1.md +7 -11
  107. package/docs/release-notes/v0.24.10.md +34 -45
  108. package/docs/release-notes/v0.24.11.md +12 -20
  109. package/docs/release-notes/v0.24.12.md +35 -48
  110. package/docs/release-notes/v0.24.13.md +34 -41
  111. package/docs/release-notes/v0.24.2.md +9 -13
  112. package/docs/release-notes/v0.24.3.md +7 -11
  113. package/docs/release-notes/v0.24.4.md +6 -6
  114. package/docs/release-notes/v0.24.5.md +6 -10
  115. package/docs/release-notes/v0.24.6.md +2 -5
  116. package/docs/release-notes/v0.24.7.md +46 -75
  117. package/docs/release-notes/v0.24.8.md +58 -96
  118. package/docs/release-notes/v0.24.9.md +38 -54
  119. package/docs/release-notes/v0.25.0.md +59 -76
  120. package/docs/release-notes/v0.25.1.md +57 -81
  121. package/docs/release-notes/v0.25.2.md +51 -70
  122. package/docs/release-notes/v0.25.3.md +11 -13
  123. package/docs/release-notes/v0.25.4.md +9 -13
  124. package/docs/release-notes/v0.25.5.md +3 -5
  125. package/docs/release-notes/v0.25.6.md +20 -29
  126. package/docs/release-notes/v0.25.7.md +5 -7
  127. package/docs/release-notes/v0.25.8.md +26 -39
  128. package/docs/release-notes/v0.26.0.md +175 -646
  129. package/docs/release-notes/v0.27.0.md +4 -5
  130. package/docs/release-notes/v0.27.1.md +4 -6
  131. package/docs/release-notes/v0.27.2.md +1 -1
  132. package/docs/release-notes/v0.28.0.md +57 -124
  133. package/docs/release-notes/v0.29.0.md +89 -208
  134. package/docs/release-notes/v0.29.1.md +1 -1
  135. package/docs/release-notes/v0.29.2.md +3 -4
  136. package/docs/release-notes/v0.30.0.md +205 -0
  137. package/docs/schedules.md +280 -363
  138. package/docs/servers.md +99 -117
  139. package/docs/soul.schema.json +2 -9
  140. package/docs/souls-and-instances.md +145 -158
  141. package/docs/workspaces.md +132 -215
  142. package/lib/automations.mjs +21 -6
  143. package/lib/core.mjs +226 -74
  144. package/lib/instance-events.mjs +1 -1
  145. package/lib/instance-inspect.mjs +109 -34
  146. package/lib/instance-lifecycle.mjs +14 -1
  147. package/lib/instance-resolution.mjs +26 -27
  148. package/lib/launch-preference.mjs +87 -0
  149. package/lib/materialize.mjs +3 -3
  150. package/lib/resolve.mjs +29 -87
  151. package/lib/schedule.mjs +1 -1
  152. package/lib/teams-verbs.mjs +195 -0
  153. package/lib/teams.mjs +190 -0
  154. package/lib/triggers.mjs +2 -2
  155. package/lib/workspace.mjs +54 -147
  156. package/package-catalog.json +9 -15
  157. package/package.json +1 -1
  158. package/skills/oats-getting-started/SKILL.md +25 -13
  159. package/capabilities/oats-review/injects/review.md +0 -69
  160. package/capabilities/oats-review/oats.json +0 -10
  161. package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
  162. package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
  163. package/docs/conventions.md +0 -90
  164. package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
  165. package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
  166. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
  167. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
  168. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
  169. package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
  170. package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
  171. package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
  172. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
  173. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
  174. package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
  175. package/docs/design/2026-09-15-captured-dispatch.md +0 -127
  176. package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
  177. package/docs/design/2026-09-15-package-preparation.md +0 -100
  178. package/docs/design/2026-09-15-portable-data-contract.md +0 -121
  179. package/docs/design/2026-09-15-portable-declarations.md +0 -189
  180. package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
  181. package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
  182. package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
  183. package/docs/design/2026-09-15-source-observation.md +0 -119
  184. package/docs/design/2026-09-16-captured-admission.md +0 -77
  185. package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
  186. package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
  187. package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
  188. package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
  189. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
  190. package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
  191. package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
  192. package/docs/design/2026-09-16-portable-onboarding.md +0 -179
  193. package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
  194. package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
  195. package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
  196. package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
  197. package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
  198. package/docs/design/2026-09-17-captured-native-start.md +0 -58
  199. package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
  200. package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
  201. package/docs/design/2026-09-17-public-captured-start.md +0 -108
  202. package/docs/design/2026-09-17-public-prepare-request.md +0 -90
  203. package/docs/design/2026-09-18-captured-pi-host.md +0 -205
  204. package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
  205. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
  206. package/docs/design/2026-09-20-redesign-program-board.md +0 -142
  207. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
  208. package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
  209. package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
  210. package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
  211. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
  212. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
  213. package/docs/design/2026-09-24-phase-d-plan.md +0 -305
  214. package/docs/design/2026-09-25-teams-contract.md +0 -258
  215. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
  216. package/docs/design/desktop-ux-plan.md +0 -362
  217. package/docs/design/launch-configurations.md +0 -168
  218. package/docs/design/okf-mirror-provenance.md +0 -105
  219. package/docs/design/operations-contract.md +0 -141
  220. package/docs/oats-member.schema.json +0 -38
  221. package/skills/integration-authoring/SKILL.md +0 -84
  222. package/skills/oats-support/SKILL.md +0 -79
  223. package/skills/skill-craft/SKILL.md +0 -109
  224. package/skills/soul-craft/SKILL.md +0 -116
@@ -17,7 +17,8 @@
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_ID/OATS_TEAM_LABEL/OATS_TEAM_LABELS/OATS_TEAMS workspace team facts (may be empty)
20
+ * OATS_DEFAULT_TEAM/OATS_DEFAULT_TEAM_ID/OATS_DEFAULT_TEAM_FROM default team facts
21
+ * OATS_TEAMS JSON rows {label, team, default, from} for mapped eligible teams
21
22
  * OATS_META JSON persisted from this hook's previous spawn output (retire only)
22
23
  *
23
24
  * Output (spawn, stdout JSON):
@@ -37,13 +38,13 @@
37
38
  * identity joined moments before the failure must still be deletable.
38
39
  */
39
40
  import { execFileSync } from "node:child_process";
40
- import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
41
+ import { appendFileSync, chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
41
42
  import { hostname } from "node:os";
42
- import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
43
+ import { join, dirname, resolve, delimiter, isAbsolute, relative } from "node:path";
43
44
  import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
44
45
  import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
45
46
  import { runCapturedNative } from "../lib/captured-native.mjs";
46
- import { CUSTODY_ATTACH_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
+ import { AW_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
48
  import { custodyPreflight } from "../lib/grant-custody.mjs";
48
49
  import { runtimeDeliveryFor, wakeRegistration } from "../lib/wake-receive.mjs";
49
50
 
@@ -100,21 +101,18 @@ const parseSecretJson = (text, what) => {
100
101
  * `command -v`, which is a SHELL BUILTIN — spawning it as a program depends on
101
102
  * a /usr/bin/command binary that many systems do not ship, and its absence
102
103
  * would read as "aw is missing" on every such host. */
103
- /** The installed aw's version from `aw version` ("aw 1.36.1 ..."), or
104
- * undefined when it cannot be read; compared as numeric triples. */
104
+ /** The installed aw version from `aw version`, or undefined when it cannot be read. */
105
105
  function awVersionTriple() {
106
106
  try { return /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return undefined; }
107
107
  }
108
- function awAtLeast(floor) {
109
- const v = awVersionTriple();
110
- if (!v) return false;
111
- const a = v.slice(1, 4).map(Number), b = floor.split(".").map(Number);
112
- for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; }
108
+ function semverAtLeast(version, floor) {
109
+ const a = version.split(".").map(Number), b = floor.split(".").map(Number);
110
+ for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0); }
113
111
  return true;
114
112
  }
115
113
  function awVersionLabel() {
116
114
  const v = awVersionTriple();
117
- return v ? v.slice(1, 4).join(".") : "unknown";
115
+ return v ? v.slice(1, 4).join(".") : undefined;
118
116
  }
119
117
 
120
118
  function onPath(cmd) {
@@ -188,7 +186,7 @@ try {
188
186
  const manifest = JSON.parse(readFileSync(new URL("../oats.json", import.meta.url), "utf8"));
189
187
  const selected = requireCapturedAwebAction(loaded, event, manifest);
190
188
  const settings = parseBindingJson(Buffer.from(process.env.OATS_SETTINGS || "{}"));
191
- if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => !["delivery", "team", "root", "roots"].includes(k))) throw new Error("captured settings support delivery/team/root readiness only; no identity copying or ambient fallback");
189
+ if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => !["delivery", "root", "roots"].includes(k))) throw new Error("captured settings support delivery/root readiness only; no identity copying or ambient fallback");
192
190
  const checked = assessCapturedSessionReadiness({ binding: selected.binding, invocation: selected.context, settings }, {
193
191
  query(args, options) { selected.assertCurrent(); const result = querySelectedKernel(args, options); selected.assertCurrent(); return result; },
194
192
  });
@@ -207,7 +205,7 @@ const AWEB_ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
207
205
  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 '_'";
208
206
  const ALIAS_REUSE_REMEDY = "spawn with a different --name (kernels 0.26.0+) or a different --purpose";
209
207
  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";
210
- const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);
208
+ const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_DEFAULT_TEAM);
211
209
  const isClassicEnvironment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
212
210
  // Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
213
211
  // delivery: "channel" (default) keeps the native channel packages waking the
@@ -216,6 +214,9 @@ const isClassicEnvironment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspac
216
214
  // flag is omitted, and nothing wakes the instance until the broker exists.
217
215
  let settings = {};
218
216
  try { settings = JSON.parse(process.env.OATS_SETTINGS || "{}"); } catch { settings = {}; }
217
+ const TEAM_SETTING_MESSAGE = "teams are not a setting since oats.aweb 1.17 / OATS 0.30: use oats teams / oats soul teams";
218
+ const hasStaleTeamSetting = settings && typeof settings === "object" && !Array.isArray(settings) && Object.hasOwn(settings, "team");
219
+ if (hasStaleTeamSetting && ["spawn", "launch", "setup", "teams", "join", "leave", "roster"].includes(event)) fatal(TEAM_SETTING_MESSAGE);
219
220
  const deliveryMode = (() => {
220
221
  const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
221
222
  return v === "session" ? "session" : "channel";
@@ -226,21 +227,22 @@ if (isClassicEnvironment() && ["spawn", "setup"].includes(event)) {
226
227
  if (event === "setup") { console.error(CLASSIC_REFUSAL); process.exit(1); }
227
228
  fatal(CLASSIC_REFUSAL);
228
229
  }
229
- if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
230
+ if (!["local", "global"].includes(identityMode) && ["spawn"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
230
231
  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`);
231
- const payloadTeam = () => {
232
- const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
233
- return { team: fromSettings, payload: fromSettings, env: process.env.OATS_TEAM_ID || undefined };
234
- };
232
+ const NO_TEAMS_MESSAGE = "no teams configured: run `oats aweb setup`";
233
+ const unmappedDefaultMessage = (label = defaultTeamLabel()) => `the default team ${label} has no provider id yet: its owner runs oats aweb setup, then commits the id, or choose another default with oats teams default`;
234
+ const defaultTeamLabel = () => typeof process.env.OATS_DEFAULT_TEAM === "string" && process.env.OATS_DEFAULT_TEAM.trim() ? process.env.OATS_DEFAULT_TEAM.trim() : undefined;
235
+ const defaultTeamId = () => typeof process.env.OATS_DEFAULT_TEAM_ID === "string" && process.env.OATS_DEFAULT_TEAM_ID.trim() ? process.env.OATS_DEFAULT_TEAM_ID.trim() : undefined;
236
+ const defaultTeamFromEnv = () => process.env.OATS_DEFAULT_TEAM_FROM === "soul" ? "soul" : "deployment";
235
237
  const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
236
- const teamConfigRemedy = () => "set settings.oats.aweb.team or keep an active team at the aweb root";
237
- function declaredRootCandidate(team = payloadTeam().team) {
238
+ const teamConfigRemedy = () => defaultTeamLabel() && !defaultTeamId() ? unmappedDefaultMessage() : NO_TEAMS_MESSAGE;
239
+ function declaredRootCandidate(team = defaultTeamId()) {
238
240
  const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
239
241
  if (team && typeof roots[team] === "string" && roots[team].trim()) return { root: roots[team].trim(), key: `settings.oats.aweb.roots[${JSON.stringify(team)}]`, declared: true };
240
242
  if (typeof settings.root === "string" && settings.root.trim()) return { root: settings.root.trim(), key: "settings.oats.aweb.root", declared: true };
241
243
  return undefined;
242
244
  }
243
- function rootSettingCandidate(team = payloadTeam().team) {
245
+ function rootSettingCandidate(team = defaultTeamId()) {
244
246
  const declared = declaredRootCandidate(team);
245
247
  if (declared) return declared;
246
248
  const fallback = process.env.OATS_WORKSPACE || process.cwd();
@@ -268,14 +270,134 @@ function awebRoot() { return resolveAwebRoot(); }
268
270
  * name-only team config into a blocked spawn (reviewer-602627c). */
269
271
  const teamMemberships = (listed) => listed?.memberships || listed?.teams || [];
270
272
  const teamIdsOf = (listed) => teamMemberships(listed).map((m) => m.team_id || m.id || m);
273
+ function normalizeAwebTeamName(label) {
274
+ const normalized = String(label || "").toLowerCase().replace(/[_.]+/g, "-").replace(/^-+|-+$/g, "");
275
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(normalized) || normalized.length > 128) throw new Error(`team label ${JSON.stringify(label)} does not normalize to a valid aweb team name`);
276
+ return normalized;
277
+ }
278
+ const AWEB_TEAM_ID_MESSAGE = "aweb team ids must have shape <name>:<namespace> (name matches ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$; namespace is a hostname)";
279
+ function validHostname(value) { const s = String(value || ""); return s.length <= 253 && s.split(".").every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label)); }
280
+ function validAwebTeamId(value) { const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,127}):([^:]+)$/.exec(String(value || "")); return !!m && validHostname(m[2]); }
281
+ function invalidAwebTeamId(env = process.env) {
282
+ const id = defaultTeamId();
283
+ if (id && !validAwebTeamId(id)) return id;
284
+ try { const rows = JSON.parse(env.OATS_TEAMS || "[]"); if (Array.isArray(rows)) for (const r of rows) if (r && typeof r.team === "string" && r.team.trim() && !validAwebTeamId(r.team.trim())) return r.team.trim(); } catch { /* malformed OATS_TEAMS is handled as no eligible teams */ }
285
+ return null;
286
+ }
287
+ const flagEq = (name, value) => `${name}=${String(value)}`;
288
+ function assertNotFlag(value, what) {
289
+ if (String(value || "").startsWith("-")) throw new Error(`${what} must not start with '-'`);
290
+ }
291
+ const yamlQuote = (value) => JSON.stringify(String(value));
292
+ function findOatsLocal(start = process.env.OATS_WORKSPACE || process.cwd()) {
293
+ let dir = resolve(start);
294
+ for (;;) {
295
+ const file = join(dir, "oats-local.yaml");
296
+ if (existsSync(file)) return file;
297
+ const parent = dirname(dir);
298
+ if (parent === dir) return join(resolve(start), "oats-local.yaml");
299
+ dir = parent;
300
+ }
301
+ }
302
+ function blockEnd(lines, start, indent) {
303
+ let end = lines.length;
304
+ for (let i = start + 1; i < lines.length; i++) if (lines[i].trim() && !lines[i].startsWith(" ".repeat(indent + 1))) { end = i; break; }
305
+ return end;
306
+ }
307
+ function ensureYamlBlock(lines, parentStart, parentEnd, indent, header) {
308
+ const row = `${" ".repeat(indent)}${header}:`;
309
+ for (let i = parentStart + 1; i < parentEnd; i++) if (lines[i].trimEnd() === row) return i;
310
+ lines.splice(parentEnd, 0, row);
311
+ return parentEnd;
312
+ }
313
+ function atomicWrite(file, text) {
314
+ mkdirSync(dirname(file), { recursive: true });
315
+ const tmp = join(dirname(file), `.${basenameForTemp(file)}.${process.pid}.${Date.now()}.tmp`);
316
+ writeFileSync(tmp, text, { mode: 0o600 });
317
+ renameSync(tmp, file);
318
+ }
319
+ function basenameForTemp(file) { return file.split(/[\\/]/).pop() || "oats-local.yaml"; }
320
+ function unsupportedLocalYaml(file, detail, team, rootDir) {
321
+ throw new Error(`${file}: cannot safely update settings.oats.aweb.roots automatically (${detail}); add this line by hand under block-style settings.oats.aweb.roots: ${yamlQuote(team)}: ${yamlQuote(rootDir)}`);
322
+ }
323
+ function findBlockHeader(lines, start, end, indent, names) {
324
+ const pad = " ".repeat(indent);
325
+ for (let i = start; i < end; i++) {
326
+ const line = lines[i];
327
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
328
+ if (!line.startsWith(pad) || line.startsWith(pad + " ")) continue;
329
+ const trimmed = line.slice(indent).trimEnd();
330
+ for (const name of names) if (trimmed === `${name}:`) return i;
331
+ for (const name of names) if (trimmed.startsWith(`${name}:`)) return { unsupported: i, line };
332
+ }
333
+ return -1;
334
+ }
335
+ function assertAwebRootSettingRecordable(team, rootDir, { start = process.env.OATS_WORKSPACE || process.cwd() } = {}) {
336
+ const file = findOatsLocal(start);
337
+ if (!existsSync(file)) return;
338
+ const lines = readFileSync(file, "utf8").split(/\r?\n/);
339
+ let settings = findBlockHeader(lines, 0, lines.length, 0, ["settings"]);
340
+ if (typeof settings === "object") unsupportedLocalYaml(file, `line ${settings.unsupported + 1} is not a block-style settings: mapping`, team, rootDir);
341
+ if (settings < 0) return;
342
+ const settingsEnd = blockEnd(lines, settings, 0);
343
+ let aweb = findBlockHeader(lines, settings + 1, settingsEnd, 2, ["oats.aweb", '"oats.aweb"', "'oats.aweb'"]);
344
+ if (typeof aweb === "object") unsupportedLocalYaml(file, `line ${aweb.unsupported + 1} is not a block-style oats.aweb: mapping`, team, rootDir);
345
+ if (aweb < 0) return;
346
+ const awebEnd = blockEnd(lines, aweb, 2);
347
+ const roots = findBlockHeader(lines, aweb + 1, awebEnd, 4, ["roots"]);
348
+ if (typeof roots === "object") unsupportedLocalYaml(file, `line ${roots.unsupported + 1} is not a block-style roots: mapping`, team, rootDir);
349
+ }
350
+ function recordAwebRootSetting(team, rootDir, { start = process.env.OATS_WORKSPACE || process.cwd() } = {}) {
351
+ const file = findOatsLocal(start);
352
+ const existed = existsSync(file);
353
+ const lines = existed ? readFileSync(file, "utf8").split(/\r?\n/) : ["schemaVersion: 2", "workspace: local"];
354
+ while (lines.length && lines.at(-1) === "") lines.pop();
355
+ let settings = findBlockHeader(lines, 0, lines.length, 0, ["settings"]);
356
+ if (typeof settings === "object") unsupportedLocalYaml(file, `line ${settings.unsupported + 1} is not a block-style settings: mapping`, team, rootDir);
357
+ if (settings < 0) { lines.push("settings:"); settings = lines.length - 1; }
358
+ let settingsEnd = blockEnd(lines, settings, 0);
359
+ let aweb = findBlockHeader(lines, settings + 1, settingsEnd, 2, ["oats.aweb", '"oats.aweb"', "'oats.aweb'"]);
360
+ if (typeof aweb === "object") unsupportedLocalYaml(file, `line ${aweb.unsupported + 1} is not a block-style oats.aweb: mapping`, team, rootDir);
361
+ if (aweb < 0) { aweb = ensureYamlBlock(lines, settings, settingsEnd, 2, "oats.aweb"); settingsEnd++; }
362
+ let awebEnd = blockEnd(lines, aweb, 2);
363
+ let roots = findBlockHeader(lines, aweb + 1, awebEnd, 4, ["roots"]);
364
+ if (typeof roots === "object") unsupportedLocalYaml(file, `line ${roots.unsupported + 1} is not a block-style roots: mapping`, team, rootDir);
365
+ if (roots < 0) { roots = ensureYamlBlock(lines, aweb, awebEnd, 4, "roots"); awebEnd++; }
366
+ const rootsEnd = blockEnd(lines, roots, 4);
367
+ const key = yamlQuote(team), value = yamlQuote(rootDir), row = ` ${key}: ${value}`;
368
+ const existing = lines.findIndex((l, i) => i > roots && i < rootsEnd && l.trimStart().startsWith(`${key}:`));
369
+ if (existing >= 0) lines[existing] = row; else lines.splice(rootsEnd, 0, row);
370
+ atomicWrite(file, `${lines.join("\n")}\n`);
371
+ }
372
+ function perTeamRoot(base, label) { return join(resolve(base), ".aweb-roots", normalizeAwebTeamName(label)); }
373
+
374
+ function awFloorProblem() {
375
+ if (!onPath("aw")) return `aw CLI not on PATH; install aw >= ${AW_MIN}`;
376
+ const version = awVersionLabel();
377
+ if (!version) return `aw version could not be read; install aw >= ${AW_MIN}`;
378
+ if (semverAtLeast(version, AW_MIN)) return undefined;
379
+ return `aw ${version} is older than required ${AW_MIN}; install aw >= ${AW_MIN}`;
380
+ }
271
381
 
272
382
  const AW_INSTALL = "install the aw CLI first — see https://aweb.ai/docs (or `oats aweb setup` for guided onboarding)";
273
383
  const isCommand = ["roster", "setup", "teams", "join", "leave"].includes(event);
384
+ const invalidConfiguredTeamId = invalidAwebTeamId();
385
+ if (invalidConfiguredTeamId && (isCommand || ["spawn", "launch"].includes(event))) {
386
+ if (isCommand) { console.error(`oats aweb ${event}: ${AWEB_TEAM_ID_MESSAGE}`); process.exit(1); }
387
+ fatal(AWEB_TEAM_ID_MESSAGE);
388
+ }
274
389
  if (!onPath("aw")) {
275
390
  if (isCommand) { console.error(`oats aweb ${event}: aw CLI not on PATH — ${AW_INSTALL}`); process.exit(1); }
276
391
  if (event === "spawn") fatal(`aw CLI not on PATH, so no identity could be minted and this instance would have no messaging — ${AW_INSTALL}`);
277
392
  warn(`aw CLI not on PATH — no identity minted; ${AW_INSTALL}`);
278
393
  }
394
+ if (isCommand || event === "spawn") {
395
+ const floorProblem = awFloorProblem();
396
+ if (floorProblem) {
397
+ if (isCommand) { console.error(`oats aweb ${event}: ${floorProblem}`); process.exit(1); }
398
+ fatal(`${floorProblem}, so no identity could be minted and this instance would not meet the messaging contract`);
399
+ }
400
+ }
279
401
 
280
402
  // ---------------------------------------------------------------------------
281
403
  // Retained identity (explicit per-soul opt-in): a standing seat keeps its
@@ -307,10 +429,6 @@ function wakeRegister(instanceHome, identityHome) {
307
429
  function wakeDeregister(instanceHome) {
308
430
  try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
309
431
  }
310
- // The first aw release whose grant subtree accepts `aw id grant mint --team
311
- // <team-id>` (and the full normal/reviewer scope lists): aw 1.36.2, paired with
312
- // aweb server 1.27.5 for complete custody operations. aw 1.36.1 has neither.
313
- const GRANT_TEAM_FLAG_MIN = "1.36.2";
314
432
  const NORMAL_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send", "events.read", "coord.read", "coord.write", "presence.write", "contacts.read", "contacts.write"];
315
433
  const REVIEWER_GRANT_SCOPES = ["mail.read", "chat.read", "events.read", "coord.read", "presence.write"];
316
434
  const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
@@ -428,7 +546,7 @@ function retainedLaunchOutput(meta = {}, identityHome = priorGrantHome(meta)) {
428
546
  return { ...(Object.keys(env).length ? { env } : {}), ...(launch ? { launch } : {}) };
429
547
  }
430
548
  function grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }) {
431
- return ["aw", "id", "grant", "mint", ...(awAtLeast(GRANT_TEAM_FLAG_MIN) ? ["--team", team] : []), "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--custody-socket", custodySocket, "--json"];
549
+ return ["aw", "id", "grant", "mint", flagEq("--team", team), flagEq("--scope", scopes.join(",")), flagEq("--ttl", ttl), flagEq("--label", `oats:${instance}`), flagEq("--out", grantHome), flagEq("--custody-socket", custodySocket), "--json"];
432
550
  }
433
551
  function validateMintedGrant(minted, grantHome) {
434
552
  const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
@@ -448,8 +566,8 @@ function globalGrantRenew() {
448
566
  if (oldMeta.identity?.mode !== "global" || !oldMeta.identity?.grant?.id) out(retainedLaunchOutput(oldMeta));
449
567
  const resident = String(identitySettings.resident || oldMeta.identity.resident || "");
450
568
  const custody = resolveResidentCustody(resident);
451
- const team = payloadTeam().team || oldMeta.identity.team;
452
- if (!team) fatal("identity.renew launch needs the prior grant team or settings.oats.aweb.team");
569
+ const team = defaultTeamId() || oldMeta.identity.team;
570
+ if (!team) fatal(teamConfigRemedy());
453
571
  const scopes = grantScopes();
454
572
  const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
455
573
  const oldHome = priorGrantHome(oldMeta);
@@ -458,7 +576,6 @@ function globalGrantRenew() {
458
576
  catch (e) { out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` }); }
459
577
  const custodySocket = preflightCustodySocket(preflight);
460
578
  if (!custodySocket) out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight reported no socket_path, so the grant cannot be attached to custody; keeping previous grant ${oldMeta.identity.grant.id}` });
461
- if (!awAtLeast(CUSTODY_ATTACH_MIN)) out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: aw ${awVersionLabel()} cannot attach a grant to custody (--custody-socket); grants need aw >= ${CUSTODY_ATTACH_MIN}; keeping previous grant ${oldMeta.identity.grant.id}` });
462
579
  let stamp = Math.floor(Date.now() / 1000);
463
580
  let grantHome = join(home, `.aweb-identity-${stamp}`);
464
581
  while (existsSync(grantHome)) grantHome = join(home, `.aweb-identity-${++stamp}`);
@@ -490,22 +607,17 @@ function globalGrantRenew() {
490
607
  out({ meta: newMeta, ...retainedLaunchOutput(newMeta, grantHome), ...(warning ? { warning } : {}) });
491
608
  }
492
609
  function globalGrantSpawn() {
493
- const resolvedTeam = payloadTeam();
494
610
  const resident = String(identitySettings.resident || "");
495
611
  const custody = resolveResidentCustody(resident);
496
- let team = resolvedTeam.team;
612
+ let team = defaultTeamId();
497
613
  const teamWarnings = [];
498
- const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
499
- if (!team) team = activeTeamAt(custody);
500
- if (unmappedPrimary && team) teamWarnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using the default team ${team}`);
501
- if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team or an active team at the resident custody root before minting a grant");
614
+ if (!team) fatal(teamConfigRemedy());
502
615
  const grantHome = join(home, ".aweb-identity");
503
616
  if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
504
617
  const scopes = grantScopes();
505
618
  const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
506
619
  const preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal });
507
620
  const custodySocket = requirePreflightCustodySocket(preflight);
508
- if (!awAtLeast(CUSTODY_ATTACH_MIN)) fatal(`aw ${awVersionLabel()} cannot attach a grant to custody (--custody-socket); grants need aw >= ${CUSTODY_ATTACH_MIN}`);
509
621
  let meta;
510
622
  const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
511
623
  const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
@@ -525,7 +637,7 @@ function globalGrantSpawn() {
525
637
  const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = validateMintedGrant(minted, grantHome);
526
638
  const recovered = recoverGrantHome(grantHome);
527
639
  const alias = recovered.subjectAlias || mintedAlias || resident;
528
- meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
640
+ meta = { delivery: deliveryMode, defaultTeam: { label: defaultTeamLabel(), team: mintedTeam, from: defaultTeamFromEnv() }, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
529
641
  if (mintedTeam !== team) {
530
642
  try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
531
643
  catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
@@ -590,22 +702,6 @@ const workspaceAliasOf = (homeDir) => {
590
702
  * signing key, a team certificate and a workspace binding. */
591
703
  const joinedLate = (homeDir) => existsSync(join(homeDir, ".aw", "signing.key")) && existsSync(join(homeDir, ".aw", "team-certs")) && !!workspaceAliasOf(homeDir);
592
704
  const JOIN_TIMEOUT_MS = Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) > 0 ? Number(process.env.OATS_AWEB_JOIN_TIMEOUT_MS) : 120000;
593
- // The first aw whose joined-team external homes are fully operable: local
594
- // accept-invite under --identity-home, hosted workspace auto-connect, self-release
595
- // and joined-root E2E key publication.
596
- const JOINED_TEAMS_AW_MIN = "1.36.12";
597
- function joinedTeamsAwFloorProblem() {
598
- if (/^\d+\.\d+\.\d+$/.test(JOINED_TEAMS_AW_MIN) && awAtLeast(JOINED_TEAMS_AW_MIN)) return undefined;
599
- 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`;
600
- 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`;
601
- }
602
- function requireJoinedTeamsAwFloor() {
603
- const problem = joinedTeamsAwFloorProblem();
604
- if (!problem) return;
605
- const error = new Error(problem.replace(/^E_TEAM_AW_FLOOR: /, ""));
606
- error.code = "E_TEAM_AW_FLOOR";
607
- throw error;
608
- }
609
705
  const yamlScalar = (text, key) => {
610
706
  const m = String(text).match(new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
611
707
  return m ? m[1].trim() : undefined;
@@ -638,7 +734,7 @@ function retainedSeatSpawn(source, takeOver) {
638
734
  const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
639
735
  if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
640
736
  const role = yamlScalar(srcWorkspace, "role_name");
641
- let team = payloadTeam().team;
737
+ let team = defaultTeamId();
642
738
  if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
643
739
  if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (${teamConfigRemedy()}, or keep an active team in ${join(source, "teams.yaml")})`);
644
740
  const dest = join(home, ".aw");
@@ -662,7 +758,7 @@ function retainedSeatSpawn(source, takeOver) {
662
758
  // copy; if the restore fails, KEEP the copy so the seat stays recoverable.
663
759
  let restored = !connected;
664
760
  if (connected) {
665
- try { run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], legacyHome, 60000); restored = true; }
761
+ try { run(["aw", "workspace", "connect", flagEq("--service", service), flagEq("--team", team), ...(role ? [flagEq("--role", role)] : [])], legacyHome, 60000); restored = true; }
666
762
  catch { restored = false; }
667
763
  }
668
764
  if (restored) { try { rmSync(dest, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(lockPath, { force: true }); } catch { /* best effort */ } }
@@ -683,7 +779,7 @@ function retainedSeatSpawn(source, takeOver) {
683
779
  for (const forbidden of ["workspace.yaml", "context", "interaction-log.jsonl", "channel-delivered-ids.json", "chat-delivered-ids.json"]) {
684
780
  if (existsSync(join(dest, forbidden))) rmSync(join(dest, forbidden), { recursive: true, force: true });
685
781
  }
686
- run(["aw", "workspace", "connect", "--service", service, "--team", team, ...(role ? ["--role", role] : [])], home, 60000);
782
+ run(["aw", "workspace", "connect", flagEq("--service", service), flagEq("--team", team), ...(role ? [flagEq("--role", role)] : [])], home, 60000);
687
783
  connected = true;
688
784
  run(["aw", "check", "--online"], home, 60000);
689
785
  run(["aw", "heartbeat"], home, 60000);
@@ -742,23 +838,24 @@ const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
742
838
  function parseOatsTeams(env = process.env) {
743
839
  try {
744
840
  const rows = JSON.parse(env.OATS_TEAMS || "[]");
745
- 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 : {} })) : [];
841
+ return Array.isArray(rows) ? rows.filter((r) => r && typeof r === "object").map((r) => ({
842
+ label: String(r.label || ""),
843
+ team: typeof r.team === "string" ? r.team : null,
844
+ default: r.default === true,
845
+ from: r.from === "shared" ? "shared" : "local",
846
+ })) : [];
746
847
  } catch { return []; }
747
848
  }
748
- function eligibleTeams() { return parseOatsTeams().filter((t) => t.mapped && t.label && t.team); }
849
+ function eligibleTeams() { return parseOatsTeams().filter((t) => !t.default && t.label && t.team); }
749
850
  function labelsCsv(text) { return String(text || "").split(",").map((s) => s.trim()).filter(Boolean); }
750
- function primaryTeamLabel() { return process.env.OATS_TEAM_LABEL || labelsCsv(process.env.OATS_TEAM_LABELS)[0] || null; }
851
+ function primaryTeamLabel() { return defaultTeamLabel() || null; }
751
852
  function requestedJoinLabels() { return labelsCsv(settings.join); }
752
- function unmappedPrimaryRow() {
753
- const primary = primaryTeamLabel();
754
- return primary ? parseOatsTeams().find((t) => t.label === primary && !t.mapped) : undefined;
755
- }
756
853
  function validateJoinLabels(labels, { action = "join" } = {}) {
757
854
  const eligible = eligibleTeams();
758
855
  const byLabel = new Map(eligible.map((t) => [t.label, t]));
759
856
  for (const label of labels) {
760
- if (action === "leave" && label === "default") {
761
- const error = new Error(`E_TEAM_DEFAULT: ${label} is the workspace's default team and cannot be left`);
857
+ if (action === "leave" && label === defaultTeamLabel()) {
858
+ const error = new Error(`E_TEAM_DEFAULT: ${label} is the default team and cannot be left`);
762
859
  error.code = "E_TEAM_DEFAULT";
763
860
  throw error;
764
861
  }
@@ -772,99 +869,133 @@ function validateJoinLabels(labels, { action = "join" } = {}) {
772
869
  return labels.map((label) => byLabel.get(label));
773
870
  }
774
871
  const joinedTeamsOf = (meta = {}) => Array.isArray(meta.joinedTeams) ? meta.joinedTeams.filter((j) => j && typeof j === "object" && j.label && j.team && j.identityHome) : [];
872
+ const leftTeamsOf = (meta = {}) => Array.isArray(meta.left) ? meta.left.filter((j) => j && typeof j === "object" && j.label && j.team && j.at && j.reason).slice(-20) : [];
775
873
  const providerStateDir = () => join(home, ".oats-aweb");
776
874
  const providerTeamsFile = () => join(providerStateDir(), "teams.json");
777
875
  function readProviderTeamsState(meta = {}) {
778
876
  try {
779
877
  const doc = JSON.parse(readFileSync(providerTeamsFile(), "utf8"));
780
- return { joinedTeams: joinedTeamsOf(doc), wakeJoined: doc.wakeJoined === true };
781
- } catch { return { joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true }; }
878
+ return { joinedTeams: joinedTeamsOf(doc), wakeJoined: doc.wakeJoined === true, left: leftTeamsOf(doc) };
879
+ } catch { return { joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true, left: leftTeamsOf(meta) }; }
782
880
  }
783
881
  function writeProviderTeamsState(meta) {
784
882
  mkdirSync(providerStateDir(), { recursive: true, mode: 0o700 });
785
- writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true }, null, 2) + "\n", { mode: 0o600 });
883
+ writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true, left: leftTeamsOf(meta) }, null, 2) + "\n", { mode: 0o600 });
786
884
  }
787
- function withProviderTeams(meta = {}) { const state = readProviderTeamsState(meta); return { ...meta, joinedTeams: state.joinedTeams, wakeJoined: state.wakeJoined }; }
885
+ function withProviderTeams(meta = {}) { const state = readProviderTeamsState(meta); return { ...meta, joinedTeams: state.joinedTeams, wakeJoined: state.wakeJoined, left: state.left }; }
788
886
  function identityHomeForLabel(label) { return join(home, `.aweb-identity-${label}`); }
789
887
  function awWithIdentity(identityHome, args) { return ["aw", "--identity-home", identityHome, ...args]; }
790
888
  function commandOutput(e) { return [e?.stdout, e?.stderr, e?.message].filter(Boolean).join("\n"); }
791
- function workspaceConnectRecovery(text) {
792
- 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);
793
- return m ? m[0].trim() : undefined;
794
- }
795
889
  function joinedWorkspacePresent(identityHome) { return existsSync(join(identityHome, "workspace.yaml")); }
890
+ function serviceForJoinedTeam(root, label, accepted, ...docs) {
891
+ const fromDocs = [...docs, accepted].map((d) => d && (d.service || d.service_url || d.aweb_url || d.workspace?.service || d.workspace?.service_url || d.workspace?.aweb_url)).find(Boolean);
892
+ if (fromDocs) return fromDocs;
893
+ const rootWorkspace = root ? join(resolve(root), ".aw", "workspace.yaml") : undefined;
894
+ const fromRoot = rootWorkspace && existsSync(rootWorkspace) ? yamlScalar(readFileSync(rootWorkspace, "utf8"), "aweb_url") : undefined;
895
+ if (fromRoot) return fromRoot;
896
+ throw new Error(`cannot determine the aweb service for joined team ${label} from ${root || "(unknown root)"}; pass --service <url> (ask the team owner; hosted aweb is https://app.aweb.ai/api)`);
897
+ }
796
898
  function releaseReceiptStatus(doc) {
797
899
  return doc && typeof doc === "object" && doc.alias_released === true ? "released" : undefined;
798
900
  }
901
+ function cleanupJoinedIdentity(entry, fallbackAlias, cwd = home) {
902
+ let receipt, released;
903
+ const alias = entry.alias || fallbackAlias;
904
+ if (!alias) throw new Error(`failed to leave team ${entry.label}; no alias is known for ${entry.identityHome}`);
905
+ try {
906
+ receipt = parseAwJson(run(awWithIdentity(entry.identityHome, ["workspace", "delete", alias, "--json"]), cwd, 60000), "aw workspace delete");
907
+ released = releaseReceiptStatus(receipt);
908
+ } catch (e) {
909
+ throw new Error(`failed to leave team ${entry.label}; kept ${entry.identityHome} so cleanup can be retried: ${String(commandOutput(e)).slice(0, 300)}`);
910
+ }
911
+ if (!released) throw new Error(`failed to leave team ${entry.label}; workspace delete did not report alias_released: true; kept ${entry.identityHome} so cleanup can be retried: ${JSON.stringify(receipt)}`);
912
+ try { rmSync(entry.identityHome, { recursive: true, force: true }); } catch { /* best effort */ }
913
+ return { released, receipt };
914
+ }
915
+ function acceptedTeamMembership(identityHome, expectedTeam) {
916
+ const listed = parseAwJson(run(awWithIdentity(identityHome, ["id", "team", "list", "--json"]), dirname(identityHome), 60000), "aw id team list");
917
+ const ids = teamIdsOf(listed).map(String);
918
+ if (expectedTeam) return ids.includes(expectedTeam) ? expectedTeam : undefined;
919
+ return ids.length === 1 ? ids[0] : undefined;
920
+ }
921
+ function connectExistingJoinedTeam({ label, identityHome, expectedTeam, root, cwd = home, serviceDocs = [], resumeCommand }) {
922
+ const team = acceptedTeamMembership(identityHome, expectedTeam);
923
+ if (!team) throw new Error(`team root ${dirname(identityHome)} already holds an aweb identity, but aw id team list does not show ${expectedTeam || "exactly one team membership"}; choose a different label or remove the stale root deliberately`);
924
+ try {
925
+ if (!joinedWorkspacePresent(identityHome)) {
926
+ const service = serviceForJoinedTeam(root, label, {}, ...serviceDocs);
927
+ run(awWithIdentity(identityHome, ["workspace", "connect", flagEq("--service", service), flagEq("--team", team), "--json"]), cwd, 60000, { secretSafe: true });
928
+ }
929
+ if (!joinedWorkspacePresent(identityHome)) throw new Error(`workspace connect did not write ${join(identityHome, "workspace.yaml")}`);
930
+ } catch (e) {
931
+ throw new Error(`joined team ${label} resume failed: ${e.message || e}; kept ${identityHome} for retry${resumeCommand ? `; resume with: ${resumeCommand}` : ""}`);
932
+ }
933
+ return { label, team, identityHome, receive: "poll", since: new Date().toISOString() };
934
+ }
935
+ function acceptConnectVerifyJoinedTeam({ label, token, identityHome, alias: requestedAlias, expectedTeam, root, cwd = home, serviceDocs = [], cleanupOnFailure = true, resumeCommand, useAcceptedService = true }) {
936
+ if (!token || typeof token !== "string") throw new Error(`aw id team accept-invite received no invite token for ${label}`);
937
+ const raw = parseSecretJson(run(awWithIdentity(identityHome, ["id", "team", "accept-invite", token, flagEq("--name", requestedAlias), "--local", "--json"]), cwd, JOIN_TIMEOUT_MS, { secrets: [token], secretSafe: true }), "aw id team accept-invite");
938
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`aw id team accept-invite returned no usable result for joined team ${label}`);
939
+ const alias = typeof raw.alias === "string" && AWEB_ALIAS_RE.test(raw.alias) ? raw.alias : requestedAlias;
940
+ const team = typeof raw.team_id === "string" && raw.team_id ? raw.team_id : expectedTeam;
941
+ if (!team) throw new Error(`aw id team accept-invite returned no team_id for joined team ${label}`);
942
+ if (expectedTeam && team !== expectedTeam) throw new Error(`joined team ${team} differs from requested ${expectedTeam}`);
943
+ const joined = { label, team, identityHome, receive: "poll", since: new Date().toISOString(), alias };
944
+ try {
945
+ if (!joinedWorkspacePresent(identityHome)) {
946
+ const service = serviceForJoinedTeam(root, label, useAcceptedService ? raw : {}, ...serviceDocs);
947
+ run(awWithIdentity(identityHome, ["workspace", "connect", flagEq("--service", service), flagEq("--team", team), "--json"]), cwd, 60000, { secretSafe: true });
948
+ }
949
+ if (!joinedWorkspacePresent(identityHome)) throw new Error(`workspace connect did not write ${join(identityHome, "workspace.yaml")}`);
950
+ } catch (e) {
951
+ if (!cleanupOnFailure) throw new Error(`joined team ${label} was accepted but workspace connect/verification failed: ${e.message || e}; kept ${identityHome} for retry${resumeCommand ? `; resume with: ${resumeCommand}` : ""}`);
952
+ let cleanupNote;
953
+ try { cleanupJoinedIdentity(joined, requestedAlias, cwd); cleanupNote = "; the joined identity was deleted"; }
954
+ catch (cleanupError) { cleanupNote = `; cleanup failed: ${cleanupError.message || cleanupError}`; }
955
+ throw new Error(`joined team ${label} was accepted but workspace connect/verification failed: ${e.message || e}${cleanupNote}`);
956
+ }
957
+ return { raw, joined };
958
+ }
799
959
  function readCapabilityMeta() {
800
960
  if (process.env.OATS_META) { try { return withProviderTeams(JSON.parse(process.env.OATS_META || "{}")); } catch { return withProviderTeams({}); } }
801
961
  try { return withProviderTeams(JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).capabilityMeta?.["oats.aweb"] || {}); } catch { return withProviderTeams({}); }
802
962
  }
803
- function defaultTeamSource(meta = {}) {
804
- const recorded = meta.defaultTeam?.source;
805
- if (recorded === "setting" || recorded === "root") return recorded;
806
- return payloadTeam().team ? "setting" : "root";
963
+ function defaultTeamObject(meta = {}) {
964
+ const label = meta.defaultTeam?.label || defaultTeamLabel() || null;
965
+ const team = meta.defaultTeam?.team || meta.team || meta.identity?.team || defaultTeamId() || null;
966
+ if (!label && !team) return null;
967
+ const from = meta.defaultTeam?.from === "soul" ? "soul" : "deployment";
968
+ return { label, team, from };
807
969
  }
808
970
  function teamsDocument(meta = readCapabilityMeta()) {
809
971
  const teams = parseOatsTeams();
810
972
  const joined = joinedTeamsOf(meta);
811
973
  const joinedLabels = new Set(joined.map((j) => j.label));
812
- const primary = primaryTeamLabel();
813
- const defaultTeam = meta.defaultTeam?.team || meta.team || meta.identity?.team || payloadTeam().team || null;
814
974
  return {
815
- defaultTeam: { team: defaultTeam, source: defaultTeamSource(meta) },
816
- primary,
817
- eligible: teams.filter((t) => t.mapped && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label) })),
975
+ defaultTeam: defaultTeamObject(meta),
976
+ eligible: teams.filter((t) => !t.default && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label), from: t.from })),
818
977
  joined: joined.map((j) => ({ label: j.label, team: j.team, identityHome: j.identityHome, receive: j.receive || "poll", since: j.since })),
819
- unmapped: teams.filter((t) => !t.mapped).map((t) => t.label),
978
+ left: leftTeamsOf(meta),
820
979
  at: new Date().toISOString(),
821
980
  };
822
981
  }
823
982
  function mintJoinedTeam(row, meta) {
824
983
  const existing = joinedTeamsOf(meta).find((j) => j.label === row.label);
825
984
  if (existing) return { meta, joined: existing, changed: false };
826
- requireJoinedTeamsAwFloor();
827
985
  const identityHome = identityHomeForLabel(row.label);
828
986
  const root = awebRootForTeam(row.team);
829
987
  if (!root) throw new Error(`${awebRootProblem(rootSettingCandidate(row.team))}, so team ${row.label} could not be joined`);
830
- const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", row.team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
988
+ const inv = parseSecretJson(run(["aw", "team", "invite", flagEq("--team-id", row.team), "--json"], root, 45000, { secretSafe: true }), "aw team invite");
831
989
  if (!inv?.token || typeof inv.token !== "string") throw new Error(`aw team invite returned no usable token for ${row.label}`);
832
- let raw, acceptWarning;
833
- try {
834
- 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");
835
- } catch (e) {
836
- const output = commandOutput(e);
837
- const recovery = workspaceConnectRecovery(output);
838
- let accepted;
839
- try { accepted = parseAwJson(e.stdout || "", "aw id team accept-invite"); } catch { accepted = undefined; }
840
- if (!accepted?.team_id) throw new Error(`aw id team accept-invite failed for joined team ${row.label}${recovery ? `; recovery: ${recovery}` : ""}`);
841
- raw = accepted;
842
- acceptWarning = `joined team ${row.label} was accepted but workspace connect failed${recovery ? `; recovery: ${recovery}` : "; rerun the aw workspace connect recovery command printed by aw"}`;
843
- }
844
- const alias = typeof raw.alias === "string" && AWEB_ALIAS_RE.test(raw.alias) ? raw.alias : (instance || meta.alias);
845
- const team = typeof raw.team_id === "string" && raw.team_id ? raw.team_id : row.team;
846
- if (team !== row.team) throw new Error(`joined team ${team} differs from requested ${row.team}`);
847
- const joined = { label: row.label, team, identityHome, receive: "poll", since: new Date().toISOString(), alias };
990
+ const { joined } = acceptConnectVerifyJoinedTeam({ label: row.label, token: inv.token, identityHome, alias: instance || meta.alias, expectedTeam: row.team, root, cwd: home, serviceDocs: [inv] });
848
991
  const next = { ...meta, joinedTeams: [...joinedTeamsOf(meta), joined] };
849
- if (!joinedWorkspacePresent(identityHome)) {
850
- const recovery = workspaceConnectRecovery(JSON.stringify(raw)) || raw.recovery_command || raw.recoveryCommand;
851
- 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"}` };
852
- }
853
- return { meta: next, joined, changed: true, ...(acceptWarning ? { warning: acceptWarning } : {}) };
992
+ return { meta: next, joined, changed: true };
854
993
  }
855
994
  function leaveJoinedTeam(label, meta) {
856
995
  const joined = joinedTeamsOf(meta);
857
996
  const entry = joined.find((j) => j.label === label);
858
997
  if (!entry) return { meta, changed: false };
859
- let receipt, released;
860
- try {
861
- receipt = parseAwJson(run(awWithIdentity(entry.identityHome, ["workspace", "delete", entry.alias || meta.alias || instance, "--json"]), home, 60000), "aw workspace delete");
862
- released = releaseReceiptStatus(receipt);
863
- } catch (e) {
864
- throw new Error(`failed to leave team ${label}; kept ${entry.identityHome} so cleanup can be retried: ${String(commandOutput(e)).slice(0, 300)}`);
865
- }
866
- 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)}`);
867
- try { rmSync(entry.identityHome, { recursive: true, force: true }); } catch { /* best effort */ }
998
+ const { released, receipt } = cleanupJoinedIdentity(entry, meta.alias || instance, home);
868
999
  return { meta: { ...meta, joinedTeams: joined.filter((j) => j.label !== label) }, changed: true, released, receipt };
869
1000
  }
870
1001
  function awebRootForTeam(team) {
@@ -875,7 +1006,19 @@ function awebRootForTeam(team) {
875
1006
  if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
876
1007
  return undefined;
877
1008
  }
1009
+ let forwardedSoulArg;
1010
+ function stripForwardedSoul(argv = process.argv.slice(3)) {
1011
+ const out = [];
1012
+ for (let i = 0; i < argv.length; i++) {
1013
+ const arg = argv[i];
1014
+ if (arg === "--soul") { if (i + 1 < argv.length) forwardedSoulArg = argv[++i]; continue; }
1015
+ if (arg.startsWith("--soul=")) { forwardedSoulArg = arg.slice("--soul=".length); continue; }
1016
+ out.push(arg);
1017
+ }
1018
+ return out;
1019
+ }
878
1020
  function parseHomeCommandArgs(argv = process.argv.slice(3)) {
1021
+ argv = stripForwardedSoul(argv);
879
1022
  const rest = [];
880
1023
  let json = false, labels;
881
1024
  for (let i = 0; i < argv.length; i++) {
@@ -892,9 +1035,76 @@ function parseHomeCommandArgs(argv = process.argv.slice(3)) {
892
1035
  }
893
1036
  function outputTeamsDocument(doc, json) {
894
1037
  if (json) { console.log(JSON.stringify(doc)); return; }
895
- console.log(`default team: ${doc.defaultTeam.team || "unknown"}`);
1038
+ console.log(`default team: ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.team || "unmapped"})` : "none"}`);
896
1039
  for (const row of doc.eligible) console.log(`${row.joined ? "joined" : "eligible"}: ${row.label} (${row.team})`);
897
- for (const label of doc.unmapped) console.log(`unmapped: ${label}`);
1040
+ for (const row of doc.joined || []) if (!(doc.eligible || []).some((e) => e.label === row.label)) console.log(`joined: ${row.label} (${row.team})`);
1041
+ for (const row of doc.left || []) console.log(`left: ${row.label} (${row.team}) ${row.reason}`);
1042
+ for (const warning of doc.warnings || []) console.log(`warning: ${warning}`);
1043
+ }
1044
+ const actionWarning = (warning) => String(warning || "").slice(0, 300);
1045
+ const registryOrigin = (value) => typeof value === "string" && /^https?:\/\//i.test(value.trim()) ? value.trim() : undefined;
1046
+ function registryOriginFromJoinedState(row, match, listed) {
1047
+ const fromJson = registryOrigin(match?.registry_origin || match?.registry_url || match?.registry || match?.awid_registry_url || listed?.registry_origin || listed?.registry_url || listed?.registry || listed?.awid_registry_url);
1048
+ if (fromJson) return { registry: fromJson };
1049
+ for (const file of ["identity.yaml", "teams.yaml", "workspace.yaml", "registry.yaml", "config.yaml"]) {
1050
+ try {
1051
+ const text = readFileSync(join(row.identityHome, file), "utf8");
1052
+ for (const key of ["awid_registry_url", "registry_origin", "registry_url", "registry"]) {
1053
+ const found = registryOrigin(yamlScalar(text, key));
1054
+ if (found) return { registry: found };
1055
+ }
1056
+ } catch { /* keep looking */ }
1057
+ }
1058
+ const fromEnv = registryOrigin(process.env.AWID_REGISTRY_URL);
1059
+ if (fromEnv) return { registry: fromEnv };
1060
+ return { registry: null, registryError: "registry origin could not be determined from the joined identity; command omits --registry and aw will use its default" };
1061
+ }
1062
+ function certificateIdFromJoinedCertificateFile(row) {
1063
+ const certFile = join(row.identityHome, "team-certs", `${String(row.team || "").replace(/:/g, "__")}.pem`);
1064
+ if (!existsSync(certFile)) return { found: false };
1065
+ try {
1066
+ const cert = JSON.parse(readFileSync(certFile, "utf8"));
1067
+ if (cert?.team_id !== row.team) return { found: false, error: `certificate file ${certFile} is for ${cert?.team_id || "unknown team"}, not ${row.team}` };
1068
+ const certificateId = typeof cert?.certificate_id === "string" ? cert.certificate_id.trim() : "";
1069
+ if (!certificateId) return { found: false, error: `certificate file ${certFile} did not contain a certificate_id for ${row.team}` };
1070
+ return { found: true, certificateId };
1071
+ } catch (e) {
1072
+ return { found: false, error: `could not read certificate file ${certFile}: ${e.message || e}` };
1073
+ }
1074
+ }
1075
+ function certificateIdForJoinedTeam(row) {
1076
+ const fromFile = certificateIdFromJoinedCertificateFile(row);
1077
+ let listed, match, listError;
1078
+ try {
1079
+ listed = parseAwJson(run(awWithIdentity(row.identityHome, ["id", "team", "list", "--json"]), home, 60000), "aw id team list");
1080
+ const memberships = teamMemberships(listed);
1081
+ match = memberships.find((m) => String(m?.team_id || m?.id || m?.team || m) === row.team);
1082
+ } catch (e) {
1083
+ listError = actionWarning(e.message || e);
1084
+ }
1085
+ const registry = registryOriginFromJoinedState(row, match, listed);
1086
+ if (fromFile.found) return { certificateId: fromFile.certificateId, ...registry };
1087
+ if (match) {
1088
+ const certificateId = match && typeof match === "object" ? (match.certificate_id || match.cert_id || match.certId || match.certificate?.id || match.cert?.id) : undefined;
1089
+ if (typeof certificateId === "string" && certificateId.trim()) return { certificateId: certificateId.trim(), ...registry };
1090
+ return { certificateId: null, certificateIdError: fromFile.error || `certificate id for ${row.team} was not present in the joined certificate file or aw id team list --json`, ...registry };
1091
+ }
1092
+ return { certificateId: null, certificateIdError: fromFile.error || listError || `aw id team list --json returned no membership for ${row.team}`, ...registry };
1093
+ }
1094
+ function failedLeaveDisposition(row, error) {
1095
+ const text = String(error?.message || error || "");
1096
+ const reason = /team_not_hosted/i.test(text) ? "team_not_hosted" : actionWarning(text);
1097
+ const data = { label: row.label, team: row.team, alias: row.alias || instance || null, ...certificateIdForJoinedTeam(row), at: new Date().toISOString(), reason, ...(reason === "team_not_hosted" ? { cleanup: "controller" } : {}) };
1098
+ const teamName = String(row.team || "").split(":")[0] || row.team;
1099
+ const namespace = String(row.team || "").includes(":") ? String(row.team).split(":").slice(1).join(":") : "<namespace>";
1100
+ const registryArg = data.registry ? ` --registry ${data.registry}` : "";
1101
+ const command = reason === "team_not_hosted" && data.certificateId ? `aw id team remove-member --namespace ${namespace} --team ${teamName} --cert-id ${data.certificateId}${registryArg} --json` : undefined;
1102
+ const ownerCommand = command
1103
+ ? `; controller cleanup: ${command}${data.registryError ? ` (${data.registryError})` : ""}`
1104
+ : reason === "team_not_hosted"
1105
+ ? `; controller cleanup needs the certificate id, but ${data.certificateIdError || "it could not be read"}${data.registryError ? ` (${data.registryError})` : ""}`
1106
+ : "";
1107
+ return { data: { ...data, ...(command ? { command } : {}) }, warning: `joined team ${row.label} cleanup failed: ${data.reason}${ownerCommand}` };
898
1108
  }
899
1109
  function runTeamsCommand(kind) {
900
1110
  const args = parseHomeCommandArgs();
@@ -916,8 +1126,9 @@ function runTeamsCommand(kind) {
916
1126
  const result = kind === "join" ? mintJoinedTeam(row, meta) : leaveJoinedTeam(row.label, meta);
917
1127
  meta = result.meta;
918
1128
  writeProviderTeamsState(meta);
919
- actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(result.warning ? { warning: result.warning } : {}) });
920
- if (result.warning) warnings.push(`oats-aweb: ${result.warning}`);
1129
+ const boundedWarning = result.warning ? actionWarning(result.warning) : undefined;
1130
+ actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(boundedWarning ? { warning: boundedWarning } : {}) });
1131
+ if (boundedWarning) warnings.push(`oats-aweb: ${boundedWarning}`);
921
1132
  }
922
1133
  } catch (e) {
923
1134
  // What already happened travels with the failure, so a caller can
@@ -936,28 +1147,21 @@ function runTeamsCommand(kind) {
936
1147
  outputTeamsDocument(doc, args.json);
937
1148
  }
938
1149
 
939
- /** The primary identity's team and the root that mints it, exactly as 1.14.2:
940
- * the config's `team:` (id, then name) wins, else the root's active team.
941
- * Exits through fatal() on refusal. */
1150
+ /** The primary identity's team comes only from the kernel's team-model v2
1151
+ * default env. Never fall back to the root's active team. */
942
1152
  function resolvePrimaryTeam() {
943
1153
  const warnings = [];
944
- const root = awebRoot();
945
- if (!root) fatal(`${awebRootProblem(rootSettingCandidate())}, so no identity could be minted and this instance would have no messaging`);
946
- let team = payloadTeam().team;
947
- const source = team ? "setting" : "root";
948
- const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
949
- if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
950
- if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using the default team ${team}`);
951
- if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
952
- // A bare team name (no namespace) resolves against the root's memberships.
953
- if (!team.includes(":")) {
954
- const teams = JSON.parse(run(["aw", "team", "list", "--json"], root));
955
- const match = teamIdsOf(teams).filter((tid) => String(tid).startsWith(`${team}:`));
956
- if (match.length === 1) team = match[0];
957
- else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
958
- else fatal(`no membership matching team "${team}" at ${root}, so no identity could be minted — join or create it first (aweb-team-membership skill), or ${teamConfigRemedy()}`);
959
- }
960
- return { team, root, source, warnings };
1154
+ const team = defaultTeamId();
1155
+ if (!team) fatal(teamConfigRemedy());
1156
+ const root = awebRootForTeam(team);
1157
+ if (!root) fatal(`${awebRootProblem(rootSettingCandidate(team))}, so default team ${defaultTeamLabel() || team} (${team}) could not mint this instance; run oats aweb setup or ask the team's owner for an invite`);
1158
+ return { team, root, from: defaultTeamFromEnv(), label: defaultTeamLabel(), warnings };
1159
+ }
1160
+ function appendProviderEvent(kind, data) {
1161
+ try {
1162
+ const row = { eventsApi: 2, at: new Date().toISOString(), instance, home, incarnation: null, producer: "oats.aweb", kind, data };
1163
+ appendFileSync(join(home, ".oats-events.jsonl"), JSON.stringify(row) + "\n");
1164
+ } catch { /* evidence only */ }
961
1165
  }
962
1166
 
963
1167
  /** The home's runtime. Spawn and launch run in the session's own env; a
@@ -1009,8 +1213,18 @@ if (event === "launch") {
1009
1213
  const eligible = new Set(eligibleTeams().map((t) => t.label));
1010
1214
  const warnings = [];
1011
1215
  for (const row of joined) if (!eligible.has(row.label)) {
1012
- try { oldMeta = leaveJoinedTeam(row.label, oldMeta).meta; }
1013
- catch (e) { warnings.push(`joined team ${row.label} cleanup failed: ${e.message || e}`); }
1216
+ try {
1217
+ oldMeta = leaveJoinedTeam(row.label, oldMeta).meta;
1218
+ const left = { label: row.label, team: row.team, at: new Date().toISOString(), reason: "no-longer-eligible" };
1219
+ oldMeta = { ...oldMeta, left: [...leftTeamsOf(oldMeta), left].slice(-20) };
1220
+ appendProviderEvent("aweb-team-left", left);
1221
+ warnings.push(`left joined team ${row.label} because it is no longer eligible`);
1222
+ }
1223
+ catch (e) {
1224
+ const failed = failedLeaveDisposition(row, e);
1225
+ appendProviderEvent("aweb-team-leave-failed", failed.data);
1226
+ warnings.push(failed.warning);
1227
+ }
1014
1228
  }
1015
1229
  // Re-register what remains: the runtime may differ from the last session.
1016
1230
  const synced = syncWakeReceive(oldMeta);
@@ -1037,9 +1251,9 @@ if (event === "launch") {
1037
1251
  let minted; // external identity, once `aw team join` succeeds
1038
1252
  let spawnMeta; // with joined teams, once any is accepted
1039
1253
  try {
1040
- // Team correctness: the config's `team:` block wins (id, then name), else the
1041
- // root's active team. ALWAYS pass --team-id explicitly — never inherit whatever
1042
- // team happens to be active at mint time — and verify the joined cert matches.
1254
+ // Team correctness: the kernel's default team id is the only source. ALWAYS
1255
+ // pass --team-id explicitly — never inherit whatever team happens to be
1256
+ // active at mint time — and verify the joined cert matches.
1043
1257
  // The instance name IS the discoverable alias (the team roster doubles as the
1044
1258
  // cross-machine instance directory).
1045
1259
  const primary = resolvePrimaryTeam();
@@ -1047,13 +1261,13 @@ if (event === "launch") {
1047
1261
  const warnings = [...primary.warnings];
1048
1262
  // Both of these carry the invite token — one mints it, the other spends it —
1049
1263
  // so neither their output nor their diagnostics may reach a log.
1050
- const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
1264
+ const inv = parseSecretJson(run(["aw", "team", "invite", flagEq("--team-id", team), "--json"], root, 45000, { secretSafe: true }), "aw team invite");
1051
1265
  if (!inv?.token || typeof inv.token !== "string") fatal("aw team invite returned no usable token, so no identity could be minted");
1052
1266
  let raw;
1053
1267
  try {
1054
1268
  // 120 s: a join on a slow or flapping link is slow, not broken; a killed
1055
1269
  // join that completed server-side is caught below.
1056
- raw = parseSecretJson(run(["aw", "team", "join", inv.token, "--name", instance, "--json"], home, JOIN_TIMEOUT_MS, { secrets: [inv.token], secretSafe: true }), "aw team join");
1270
+ raw = parseSecretJson(run(["aw", "team", "join", inv.token, flagEq("--name", instance), "--json"], home, JOIN_TIMEOUT_MS, { secrets: [inv.token], secretSafe: true }), "aw team join");
1057
1271
  } catch (e) {
1058
1272
  // The join may have completed after the CLI was killed or reported a
1059
1273
  // failure: if the home now holds a bound identity, that identity EXISTS
@@ -1111,13 +1325,11 @@ if (event === "launch") {
1111
1325
  const deliveryBrief = deliveryMode === "session"
1112
1326
  ? ` 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.`
1113
1327
  : "";
1114
- let meta = { team: joined.team_id, alias, delivery: deliveryMode, defaultTeam: { team: joined.team_id, source: primary.source }, ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
1115
- const joinFloorProblem = joinRows.length ? joinedTeamsAwFloorProblem() : undefined;
1116
- if (joinFloorProblem) warnings.push(`oats-aweb: ${joinFloorProblem}`);
1117
- else for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; spawnMeta = meta; writeProviderTeamsState(meta); if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
1328
+ let meta = { team: joined.team_id, alias, delivery: deliveryMode, defaultTeam: { label: primary.label, team: joined.team_id, from: primary.from }, left: [], ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
1329
+ for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; spawnMeta = meta; writeProviderTeamsState(meta); if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
1118
1330
  if (joinedTeamsOf(meta).length) { const synced = syncWakeReceive(meta); meta = synced.meta; for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`); }
1119
1331
  writeProviderTeamsState(meta);
1120
- const defaultTeamBrief = meta.defaultTeam.source === "setting" ? "the team this deployment configured for you" : "the workspace's default team (the messaging root's active team)";
1332
+ const defaultTeamBrief = meta.defaultTeam.from === "soul" ? "the default team configured for this soul" : "this deployment's default team";
1121
1333
  const joinedNow = joinedTeamsOf(meta);
1122
1334
  const joinedBrief = joinedNow.length ? ` Joined teams: ${joinedNow.map((j) => `${j.label} (${j.team}, receive ${j.receive}, send with \`aw --identity-home ${j.identityHome} mail|chat ...\`)`).join("; ")}.` : "";
1123
1335
  out({
@@ -1136,6 +1348,12 @@ if (event === "launch") {
1136
1348
  } else if (event === "retire") {
1137
1349
  let meta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
1138
1350
  const retireWarnings = [];
1351
+ const pendingControllerCleanup = [];
1352
+ const rememberControllerCleanup = (data) => {
1353
+ if (data?.cleanup === "controller") pendingControllerCleanup.push({ label: data.label, team: data.team, alias: data.alias, certificateId: data.certificateId, ...(data.command ? { command: data.command } : {}) });
1354
+ };
1355
+ const retiredMeta = (fields = {}) => ({ ...fields, ...(pendingControllerCleanup.length ? { pendingControllerCleanup } : {}) });
1356
+ if (hasStaleTeamSetting) retireWarnings.push(TEAM_SETTING_MESSAGE);
1139
1357
  // A retained seat: release the lock and leave the identity alone. Never
1140
1358
  // aw workspace delete (it would soft-delete the standing identity's row)
1141
1359
  // and never team retire; the source .aw stays until a human removes it.
@@ -1143,7 +1361,7 @@ if (event === "launch") {
1143
1361
  if (meta.identity?.mode === "global" && !meta.retained) globalGrantRetire(meta);
1144
1362
  for (const joined of joinedTeamsOf(meta)) {
1145
1363
  try { meta = leaveJoinedTeam(joined.label, meta).meta; }
1146
- catch (e) { retireWarnings.push(`joined team ${joined.label} cleanup failed: ${e.message || e}`); }
1364
+ catch (e) { const failed = failedLeaveDisposition(joined, e); retireWarnings.push(failed.warning); rememberControllerCleanup(failed.data); }
1147
1365
  }
1148
1366
  // A native (channel/pi) home registered with the broker only for its joined
1149
1367
  // teams; a session home was deregistered above.
@@ -1153,7 +1371,7 @@ if (event === "launch") {
1153
1371
  if (meta.retained) {
1154
1372
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
1155
1373
  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}` : ""}`;
1156
- out({ meta: { retired: true, retained: true, identityReleased: true, joinedTeams: joinedTeamsOf(meta), ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: ${[...retireWarnings, retainedWarning].join(" | ")}` });
1374
+ out({ meta: retiredMeta({ retired: true, retained: true, identityReleased: true, joinedTeams: joinedTeamsOf(meta), ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }), warning: `oats-aweb: ${[...retireWarnings, retainedWarning].join(" | ")}` });
1157
1375
  }
1158
1376
  // No alias means the spawn hook never reported an identity: nothing exists to
1159
1377
  // undo, which is completion. An alias WITH no local `.aw` is the opposite —
@@ -1163,37 +1381,29 @@ if (event === "launch") {
1163
1381
  // timeout that completed anyway) still carries the alias in its workspace
1164
1382
  // binding: use it rather than leaving the workspace orphaned.
1165
1383
  if (!meta.alias) { const late = workspaceAliasOf(home); if (late) meta = { ...meta, alias: late, aliasFromHome: true }; }
1166
- if (!meta.alias) out({ meta: { retired: false, reason: "nothing-to-delete" } });
1384
+ if (!meta.alias) out({ meta: retiredMeta({ retired: false, reason: "nothing-to-delete" }) });
1167
1385
  if (!existsSync(join(home, ".aw"))) {
1168
- out({ meta: { retired: false, reason: "no-local-identity-key" }, warning: `oats-aweb: alias "${meta.alias}" was minted but ${join(home, ".aw")} is gone, so the remote record cannot be self-deleted and will linger until stale` }, 1);
1386
+ out({ meta: retiredMeta({ retired: false, reason: "no-local-identity-key" }), warning: `oats-aweb: alias "${meta.alias}" was minted but ${join(home, ".aw")} is gone, so the remote record cannot be self-deleted and will linger until stale` }, 1);
1169
1387
  }
1170
1388
  try {
1171
1389
  // Self-delete from inside the home, authenticated by its own key — a remote
1172
- // delete would 409 until the server marks the workspace stale.
1173
- // aw 1.36.1 (aweb-abim) revokes the member's certificate on delete and
1174
- // says so: `--json` prints alias_released true|false with a reason, and
1175
- // a released alias may be reused by a later spawn. An older aw cannot
1176
- // revoke, so the alias stays unusable and the report says that instead.
1177
- if (awAtLeast("1.36.1")) {
1178
- const raw = run(["aw", "workspace", "delete", meta.alias, "--json"], home);
1179
- let doc; try { doc = JSON.parse(raw); } catch { doc = undefined; }
1180
- const released = doc?.alias_released === true;
1181
- // aw 1.36.1 prints the cause as alias_released_reason (workspace.go,
1182
- // workspace_self_retire.go); `reason` is tolerated for a later rename.
1183
- const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
1184
- 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` }) });
1390
+ // delete would 409 until the server marks the workspace stale. aw >= 1.36.13
1391
+ // reports whether the certificate was revoked and the alias was released.
1392
+ const raw = run(["aw", "workspace", "delete", meta.alias, "--json"], home);
1393
+ let doc; try { doc = JSON.parse(raw); } catch { doc = undefined; }
1394
+ const released = doc?.alias_released === true;
1395
+ const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
1396
+ if (!released && /team_not_hosted/i.test(reason)) {
1397
+ const failed = failedLeaveDisposition({ label: "default", team: meta.team || meta.defaultTeam?.team || meta.identity?.team || defaultTeamId(), alias: meta.alias, identityHome: join(home, ".aw") }, reason);
1398
+ retireWarnings.push(failed.warning.replace(/^joined team default cleanup failed:/, "default identity cleanup failed:"));
1399
+ rememberControllerCleanup({ ...failed.data, label: "default" });
1185
1400
  }
1186
- run(["aw", "workspace", "delete", meta.alias], home);
1187
- // Honest: the workspace row is deleted, but a hosted local member cannot
1188
- // revoke its own AWID certificate (aweb-abim), so the alias is NOT
1189
- // reusable. retired stays true because the cleanup is as complete as the
1190
- // platform allows; the field and the line carry the truth.
1191
- 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(" | ")}` });
1401
+ out({ meta: retiredMeta({ 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` }) });
1192
1402
  } catch (e) {
1193
1403
  // Exit nonzero: during a required-hook rollback this is the signal that
1194
1404
  // compensation did NOT complete, so the spawn is not reported as cleanly
1195
1405
  // rolled back while a remote identity still exists.
1196
- 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);
1406
+ out({ meta: retiredMeta({ retired: false, reason: "self-delete-failed" }), warning: `oats-aweb: self-delete failed (the remote record will linger until stale): ${e.message || e}` }, 1);
1197
1407
  }
1198
1408
  } else if (["teams", "join", "leave"].includes(event)) {
1199
1409
  try { runTeamsCommand(event); process.exit(0); }
@@ -1210,7 +1420,7 @@ if (event === "launch") {
1210
1420
  // Default: this instance's default team, listed from the root that minted
1211
1421
  // it. `--label <label>` lists an eligible (joined or not) workspace team from
1212
1422
  // the host root that holds it.
1213
- const argv = process.argv.slice(3);
1423
+ const argv = stripForwardedSoul(process.argv.slice(3));
1214
1424
  const labelAt = argv.indexOf("--label");
1215
1425
  const label = labelAt >= 0 ? argv[labelAt + 1] : (argv.find((a) => a.startsWith("--label=")) || "").slice("--label=".length) || undefined;
1216
1426
  const meta = readCapabilityMeta();
@@ -1221,15 +1431,14 @@ if (event === "launch") {
1221
1431
  team = row.team; root = awebRootForTeam(team);
1222
1432
  if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}, so the roster of ${label} cannot be read from this host`); process.exit(1); }
1223
1433
  } else {
1224
- team = meta.defaultTeam?.team || meta.identity?.team || meta.team || process.env.OATS_TEAM_ID;
1225
- root = awebRoot();
1226
- if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
1227
- if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
1434
+ team = meta.defaultTeam?.team || meta.identity?.team || meta.team || defaultTeamId();
1435
+ root = awebRootForTeam(team);
1436
+ if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}`); process.exit(1); }
1228
1437
  }
1229
- if (!team) { console.error(`oats aweb roster: cannot determine team (${teamConfigRemedy()}, or activate a team at the aweb root)`); process.exit(1); }
1230
- const teamFlag = team.includes(":") ? ["--team-id", team] : ["--team", team];
1438
+ if (!team) { console.error(`oats aweb roster: ${teamConfigRemedy()}`); process.exit(1); }
1439
+ const teamFlag = [flagEq(team.includes(":") ? "--team-id" : "--team", team)];
1231
1440
  const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
1232
- if (process.argv.includes("--json")) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
1441
+ if (argv.includes("--json")) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
1233
1442
  console.log(`aweb team ${r.team_id || team} — member roster (cross-machine):`);
1234
1443
  const members = r.members || [];
1235
1444
  if (!members.length) console.log(" (no member certificates visible from this workspace)");
@@ -1239,25 +1448,37 @@ if (event === "launch") {
1239
1448
  } else if (event === "setup") {
1240
1449
  // Guided onboarding — idempotent, prints what it finds and can run one
1241
1450
  // existing aw primitive when the operator supplies the needed authority.
1242
- const args = process.argv.slice(3).filter((arg) => arg !== "--json");
1243
- const usage = "usage: oats aweb setup [--username <hosted-user> | --invite <token>]";
1244
- let username, invite;
1451
+ const args = stripForwardedSoul(process.argv.slice(3)).filter((arg) => arg !== "--json");
1452
+ const usage = "usage: oats aweb setup [--username <hosted-user> | --create <label> [--namespace <domain>] | --join <label> [--invite <token>] [--service <url>] [--name <alias>]]";
1453
+ let username, invite, createLabel, createNamespace, joinLabel, joinService, joinName;
1245
1454
  for (let i = 0; i < args.length; i++) {
1246
1455
  const arg = args[i];
1247
1456
  if (arg === "--username" && args[i + 1]) { username = args[++i]; continue; }
1248
1457
  if (arg.startsWith("--username=") && arg.length > "--username=".length) { username = arg.slice("--username=".length); continue; }
1249
1458
  if (arg === "--invite" && args[i + 1]) { invite = args[++i]; continue; }
1250
1459
  if (arg.startsWith("--invite=") && arg.length > "--invite=".length) { invite = arg.slice("--invite=".length); continue; }
1460
+ if (arg === "--join" && args[i + 1]) { joinLabel = args[++i]; continue; }
1461
+ if (arg.startsWith("--join=") && arg.length > "--join=".length) { joinLabel = arg.slice("--join=".length); continue; }
1462
+ if (arg === "--create" && args[i + 1]) { createLabel = args[++i]; continue; }
1463
+ if (arg.startsWith("--create=") && arg.length > "--create=".length) { createLabel = arg.slice("--create=".length); continue; }
1464
+ if (arg === "--namespace" && args[i + 1]) { createNamespace = args[++i]; continue; }
1465
+ if (arg.startsWith("--namespace=") && arg.length > "--namespace=".length) { createNamespace = arg.slice("--namespace=".length); continue; }
1466
+ if (arg === "--service" && args[i + 1]) { joinService = args[++i]; continue; }
1467
+ if (arg.startsWith("--service=") && arg.length > "--service=".length) { joinService = arg.slice("--service=".length); continue; }
1468
+ if (arg === "--name" && args[i + 1]) { joinName = args[++i]; continue; }
1469
+ if (arg.startsWith("--name=") && arg.length > "--name=".length) { joinName = arg.slice("--name=".length); continue; }
1251
1470
  console.error(`oats aweb setup: ${usage}`);
1252
1471
  process.exit(2);
1253
1472
  }
1473
+ if (createNamespace && !createLabel) { console.error(`oats aweb setup: --namespace requires --create\n${usage}`); process.exit(2); }
1474
+ if (invite && !joinLabel) { console.error(`oats aweb setup: --invite requires --join <label> so the team gets its own root\n${usage}`); process.exit(2); }
1475
+ if ((joinService || joinName) && !joinLabel) { console.error(`oats aweb setup: --service/--name require --join <label>\n${usage}`); process.exit(2); }
1254
1476
  const apiKey = !!process.env.AWEB_API_KEY;
1255
- const actions = [username ? "--username" : null, invite ? "--invite" : null, apiKey ? "AWEB_API_KEY" : null].filter(Boolean);
1477
+ const actions = [username ? "--username" : null, joinLabel ? "--join" : null, createLabel ? "--create" : null, apiKey && !createLabel ? "AWEB_API_KEY" : null].filter(Boolean);
1256
1478
  if (actions.length > 1) { console.error(`oats aweb setup: choose exactly one onboarding authority (${actions.join(", ")})\n${usage}`); process.exit(2); }
1257
1479
 
1258
- const resolvedTeam = payloadTeam();
1259
- const teamName = resolvedTeam.team;
1260
- const teamId = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : process.env.OATS_TEAM_ID;
1480
+ const teamName = defaultTeamId();
1481
+ const teamId = defaultTeamId();
1261
1482
  const candidate = rootSettingCandidate(teamName);
1262
1483
  const scope = candidate?.root ? resolve(candidate.root) : process.cwd();
1263
1484
  console.log(`aweb onboarding — messaging root: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
@@ -1268,11 +1489,102 @@ if (event === "launch") {
1268
1489
 
1269
1490
  const want = teamId || teamName;
1270
1491
  const defaultTeamForUsername = username ? `default:${username}.aweb.ai` : undefined;
1271
- const readTeams = () => {
1272
- try { return parseAwJson(run(["aw", "team", "list", "--json"], scope), "aw team list"); }
1492
+ const readTeamsAt = (rootDir) => {
1493
+ try { return parseAwJson(run(["aw", "team", "list", "--json"], rootDir), "aw team list"); }
1273
1494
  catch { return { memberships: [] }; }
1274
1495
  };
1496
+ const readTeams = () => readTeamsAt(scope);
1275
1497
  const matchingTeam = (teams) => want ? teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`)) : undefined;
1498
+ const rootAlias = () => {
1499
+ try { const who = JSON.parse(run(["aw", "whoami", "--json"], scope)); return who.alias || who.name; } catch { return undefined; }
1500
+ };
1501
+ const setupAlias = () => {
1502
+ const alias = joinName || rootAlias();
1503
+ if (!alias || !AWEB_ALIAS_RE.test(alias)) throw new Error(`--name <alias> is required when no root identity is available; aliases must match the aweb 1-64 character rule`);
1504
+ return alias;
1505
+ };
1506
+ const setupResumeCommand = (label) => `oats aweb setup --soul ${setupSoulArg()} --join ${label} --service <url>`;
1507
+ const setupServiceDocs = (teamRoot) => {
1508
+ const docs = [];
1509
+ if (joinService) docs.push({ aweb_url: joinService });
1510
+ if (process.env.AWEB_URL) docs.push({ aweb_url: process.env.AWEB_URL });
1511
+ const addRoot = (rootDir) => {
1512
+ if (!rootDir || resolve(rootDir) === resolve(teamRoot || "")) return;
1513
+ try { const url = yamlScalar(readFileSync(join(resolve(rootDir), ".aw", "workspace.yaml"), "utf8"), "aweb_url"); if (url) docs.push({ aweb_url: url }); } catch { /* no service there */ }
1514
+ };
1515
+ const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
1516
+ for (const rootDir of Object.values(roots)) if (typeof rootDir === "string") addRoot(rootDir);
1517
+ if (typeof settings.root === "string") addRoot(settings.root);
1518
+ addRoot(scope);
1519
+ return docs;
1520
+ };
1521
+ const hostedCreateUnavailable = () => "creating an additional hosted team needs hosted team creation (aweb-abkh), not yet released in aw or aweb Cloud; use --namespace <domain> for a team you control, or ask the aweb team";
1522
+ const candidateTeamId = (name) => `${name}:${createNamespace}`;
1523
+ const teamExistsError = (e) => e?.status === 409 || /\b409\b|\bconflict\b|\balready exists\b|\bexists\b/i.test(commandOutput(e));
1524
+ const configuredTeamForLabel = (label) => parseOatsTeams().find((t) => t.label === label && t.team)?.team || (label === defaultTeamLabel() ? defaultTeamId() : undefined);
1525
+ const acceptIntoTeamRoot = (label, token, expectedTeam, ...serviceDocs) => {
1526
+ const teamRoot = perTeamRoot(process.env.OATS_WORKSPACE || scope, label);
1527
+ const idHome = join(teamRoot, ".aw");
1528
+ const resumeCommand = setupResumeCommand(label);
1529
+ const docs = [...setupServiceDocs(teamRoot), ...serviceDocs];
1530
+ const recordTeam = expectedTeam || configuredTeamForLabel(label) || `<team for ${label}>`;
1531
+ assertAwebRootSettingRecordable(recordTeam, teamRoot, { start: process.env.OATS_WORKSPACE || scope });
1532
+ let joined;
1533
+ if (existsSync(join(idHome, "identity.yaml"))) {
1534
+ if (existsSync(join(idHome, "workspace.yaml"))) throw new Error(`team root ${teamRoot} already holds a connected aweb identity; choose a different label or remove the stale root deliberately`);
1535
+ joined = connectExistingJoinedTeam({ label, identityHome: idHome, expectedTeam: expectedTeam || configuredTeamForLabel(label), root: scope, cwd: teamRoot, serviceDocs: docs, resumeCommand });
1536
+ } else {
1537
+ if (!token || typeof token !== "string") throw new Error(`--join ${label} needs --invite <token> unless ${teamRoot} already holds an accepted unconnected identity to resume`);
1538
+ mkdirSync(teamRoot, { recursive: true });
1539
+ try {
1540
+ ({ joined } = acceptConnectVerifyJoinedTeam({ label, token, identityHome: idHome, alias: setupAlias(), expectedTeam: expectedTeam || configuredTeamForLabel(label), root: scope, cwd: teamRoot, serviceDocs: docs, cleanupOnFailure: false, resumeCommand, useAcceptedService: false }));
1541
+ } catch (e) {
1542
+ if (!existsSync(idHome)) { try { rmSync(teamRoot, { recursive: true, force: true }); } catch { /* best effort after failure before identity creation */ } }
1543
+ throw e;
1544
+ }
1545
+ }
1546
+ recordAwebRootSetting(joined.team, teamRoot, { start: process.env.OATS_WORKSPACE || scope });
1547
+ return { team: joined.team, teamRoot };
1548
+ };
1549
+ const createTeam = (label) => {
1550
+ if (!createNamespace) throw new Error(hostedCreateUnavailable());
1551
+ const base = normalizeAwebTeamName(label);
1552
+ for (let n = 1; n <= 20; n++) {
1553
+ const name = n === 1 ? base : `${base}-${n}`;
1554
+ const expectedTeam = candidateTeamId(name);
1555
+ const createArgs = ["aw", "id", "team", "create", flagEq("--name", name), flagEq("--namespace", createNamespace), "--json"];
1556
+ try {
1557
+ const created = parseSecretJson(run(createArgs, scope, 120000, { secretSafe: true }), "aw id team create");
1558
+ if (!created?.team_id || typeof created.team_id !== "string") throw new Error("aw id team create returned no team_id");
1559
+ const team = created.team_id;
1560
+ const token = created.invite_token || created.invite || created.token;
1561
+ if (!token || typeof token !== "string") throw new Error("aw id team create returned no invite token");
1562
+ return acceptIntoTeamRoot(label, token, team, created);
1563
+ } catch (e) {
1564
+ if (!teamExistsError(e)) throw e;
1565
+ const teams = readTeams();
1566
+ if (teamIdsOf(teams).some((tid) => String(tid) === expectedTeam)) { recordAwebRootSetting(expectedTeam, scope, { start: process.env.OATS_WORKSPACE || scope }); return { team: expectedTeam, teamRoot: scope, reused: true }; }
1567
+ if (n === 20) throw new Error(`could not create a unique aweb team for ${JSON.stringify(label)} after suffixing through -20`);
1568
+ }
1569
+ }
1570
+ };
1571
+ const recordLocalTeam = (label, team) => {
1572
+ const cli = process.env.OATS_CLI_BIN;
1573
+ if (!cli) throw new Error("OATS_CLI_BIN is required to record a new local team");
1574
+ assertNotFlag(label, "team label");
1575
+ run([cli, "teams", "add", label, flagEq("--team", team)], process.cwd(), 60000);
1576
+ };
1577
+ const setupSoulArg = () => forwardedSoulArg || "<soul>";
1578
+ const missingSharedRows = () => parseOatsTeams().filter((t) => {
1579
+ if (t.from !== "shared" || !t.label || !t.team) return false;
1580
+ const teamRoot = awebRootForTeam(t.team);
1581
+ if (!teamRoot) return true;
1582
+ const ids = new Set(teamIdsOf(readTeamsAt(teamRoot)).map(String));
1583
+ return !ids.has(t.team);
1584
+ });
1585
+ const printSharedMissing = (rows) => {
1586
+ for (const row of rows) console.log(`team ${row.label} (${row.team}) is shared: ask its owner for an invite, then run \`oats aweb setup --soul ${setupSoulArg()} --join ${row.label} --invite <token>\``);
1587
+ };
1276
1588
  const printVerdict = (teams) => {
1277
1589
  const match = matchingTeam(teams);
1278
1590
  if (match) {
@@ -1287,40 +1599,62 @@ if (event === "launch") {
1287
1599
  if (!want) {
1288
1600
  console.log(` no team: ${teamConfigRemedy()}`);
1289
1601
  const active = teams.active_team || teamIdsOf(teams)[0];
1290
- if (active) console.log(` This root is a member of ${active}; map the workspace team to that id in the workspace file or settings.oats.aweb.team.`);
1602
+ if (active) console.log(` This root is a member of ${active}; record it with \`oats teams add <label> --team ${active}\` and \`oats teams default <label>\`.`);
1291
1603
  else if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; map the workspace team to that id if this is the intended team.`);
1292
1604
  return;
1293
1605
  }
1294
1606
  console.log(` Workspace initialized, but no membership matching "${want}".`);
1295
- if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; set settings.oats.aweb.team to that id, then re-run setup.`);
1607
+ if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; record it with \`oats teams add <label> --team ${defaultTeamForUsername}\` and \`oats teams default <label>\`, then re-run setup.`);
1296
1608
  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).");
1297
1609
  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).");
1298
- console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --username <u>` and creates default:<u>.aweb.ai).");
1610
+ console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --new-account --username <u>` and creates default:<u>.aweb.ai).");
1299
1611
  };
1300
1612
 
1301
1613
  try {
1302
1614
  const hasRoot = existsSync(join(scope, ".aw"));
1615
+ let teams = hasRoot ? readTeams() : { memberships: [] };
1616
+ const unmappedDefault = !actions.length && defaultTeamLabel() && !defaultTeamId();
1617
+ if (unmappedDefault) {
1618
+ console.log(`team ${defaultTeamLabel()} has no provider id yet: ask its owner to run \`oats aweb setup --create ${defaultTeamLabel()} --namespace <domain>\` and commit the id, or ask the owner for an invite and run \`oats aweb setup --join ${defaultTeamLabel()} --invite <token>\`.`);
1619
+ process.exit(1);
1620
+ }
1621
+ const missingShared = !actions.length ? missingSharedRows() : [];
1622
+ if (missingShared.length) { printSharedMissing(missingShared); process.exit(1); }
1303
1623
  if (!hasRoot && !actions.length) {
1304
1624
  console.log(`No aweb workspace at the messaging root yet (${candidate?.key || "settings.oats.aweb.root"}).`);
1305
1625
  if (!want) console.log(` Also choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
1306
1626
  console.log(" Choose one guided setup path:");
1307
- console.log(" oats aweb setup --username <u> # runs `aw init --username <u>` and creates default:<u>.aweb.ai");
1627
+ console.log(" oats aweb setup --username <u> # runs `aw init --new-account --username <u>` and creates default:<u>.aweb.ai");
1308
1628
  console.log(" AWEB_API_KEY=<key> oats aweb setup # runs `aw init` for the hosted team behind the key");
1309
- console.log(" oats aweb setup --invite <token> # runs `aw team join <token>` from an existing-team invite");
1629
+ console.log(" oats aweb setup --join <label> --invite <token> # accepts into a new per-team root");
1310
1630
  console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run setup.");
1311
1631
  console.log(" Own your domain? Use the aweb-team-membership skill for BYOT flows.");
1312
1632
  process.exit(0);
1313
1633
  }
1314
- let teams = hasRoot ? readTeams() : { memberships: [] };
1634
+ if (createLabel) {
1635
+ const label = createLabel;
1636
+ if (!createNamespace) throw new Error(hostedCreateUnavailable());
1637
+ mkdirSync(scope, { recursive: true });
1638
+ console.log(`Creating aweb team ${label} in namespace ${createNamespace} for a new per-team root.`);
1639
+ const created = createTeam(label);
1640
+ teams = readTeams();
1641
+ recordLocalTeam(label, created.team);
1642
+ console.log(`✓ created ${created.team}, ${created.reused ? `reused existing member root ${created.teamRoot}` : `accepted it into ${created.teamRoot}`}, recorded roots[${created.team}], and recorded local team ${label} with \`oats teams add ${label} --team ${created.team}\`.`);
1643
+ printVerdict(teams);
1644
+ process.exit(0);
1645
+ }
1646
+ if (joinLabel) {
1647
+ console.log(invite ? `Accepting invite for ${joinLabel} into a new per-team root (token withheld).` : `Resuming join for ${joinLabel} in its per-team root.`);
1648
+ const accepted = acceptIntoTeamRoot(joinLabel, invite, undefined);
1649
+ console.log(`✓ joined ${accepted.team} as ${joinLabel} in ${accepted.teamRoot}; recorded settings.oats.aweb.roots[${accepted.team}].`);
1650
+ process.exit(0);
1651
+ }
1315
1652
  if (hasRoot && !matchingTeam(teams) && !actions.length) { printVerdict(teams); process.exit(0); }
1316
1653
  if (!hasRoot || !matchingTeam(teams)) {
1317
1654
  if (actions.length) mkdirSync(scope, { recursive: true });
1318
1655
  if (username) {
1319
- console.log(`Running aw init --username <u> at ${scope} (username withheld from repeated logs).`);
1320
- run(["aw", "init", "--username", username], scope, 120000, { secrets: [username], unsetEnv: ["AWEB_API_KEY"] });
1321
- } else if (invite) {
1322
- console.log(`Running aw team join <token> at ${scope} (token withheld).`);
1323
- run(["aw", "team", "join", invite], scope, 120000, { secrets: [invite], secretSafe: true });
1656
+ console.log(`Running aw init --new-account --username <u> at ${scope} (username withheld from repeated logs).`);
1657
+ run(["aw", "init", "--new-account", "--username", username], scope, 120000, { secrets: [username], unsetEnv: ["AWEB_API_KEY"] });
1324
1658
  } else if (apiKey) {
1325
1659
  console.log(`Running aw init at ${scope} with AWEB_API_KEY from the environment (key withheld).`);
1326
1660
  run(["aw", "init"], scope, 120000, { secretSafe: true });