@awebai/oats 0.25.9 → 0.26.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 (177) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +8 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-review/oats.json +3 -2
  20. package/docs/capabilities.md +218 -47
  21. package/docs/capability-manifest.schema.json +13 -4
  22. package/docs/configuration.md +17 -5
  23. package/docs/conventions.md +16 -26
  24. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  25. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  26. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  27. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  28. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  29. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  30. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  31. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  32. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
  33. package/docs/design/2026-09-24-phase-d-plan.md +57 -0
  34. package/docs/design/2026-09-25-teams-contract.md +226 -0
  35. package/docs/design/README.md +3 -3
  36. package/docs/design/launch-configurations.md +20 -16
  37. package/docs/design/operations-contract.md +27 -10
  38. package/docs/desktop-cli-api.md +537 -261
  39. package/docs/desktop-instance-start.md +1 -1
  40. package/docs/desktop.md +7 -13
  41. package/docs/execution-targets.md +16 -18
  42. package/docs/first-team.md +14 -17
  43. package/docs/implementation.md +28 -59
  44. package/docs/integrations.md +64 -33
  45. package/docs/knowledge-capability-authoring.md +1 -1
  46. package/docs/knowledge-reference/package-craft.md +10 -8
  47. package/docs/knowledge-theory.md +1 -1
  48. package/docs/knowledge.md +10 -11
  49. package/docs/layers.md +16 -17
  50. package/docs/oats-local.schema.json +29 -1
  51. package/docs/oats-membership.schema.json +5 -3
  52. package/docs/oats-package.schema.json +2 -2
  53. package/docs/oats-workspace.schema.json +1 -1
  54. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  55. package/docs/packages.md +75 -52
  56. package/docs/release-notes/v0.22.0.md +1 -1
  57. package/docs/release-notes/v0.23.1.md +1 -1
  58. package/docs/release-notes/v0.26.0.md +670 -0
  59. package/docs/schedules.md +48 -126
  60. package/docs/soul.schema.json +11 -4
  61. package/docs/souls-and-instances.md +56 -43
  62. package/docs/workspaces.md +80 -58
  63. package/injects/instance-boundary.md +1 -1
  64. package/injects/work-attached.md +1 -1
  65. package/injects/work-workspace.md +2 -2
  66. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  67. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  68. package/lib/capability-contract.mjs +110 -0
  69. package/lib/config-data.mjs +2 -2
  70. package/lib/core.mjs +700 -4824
  71. package/lib/digest.mjs +12 -0
  72. package/lib/instance-inspect.mjs +396 -0
  73. package/lib/instance-lifecycle.mjs +3 -4
  74. package/lib/instance-resolution.mjs +212 -26
  75. package/lib/instruction-composition.mjs +0 -20
  76. package/lib/materialize.mjs +6 -4
  77. package/lib/operator-dispatch.mjs +33 -13
  78. package/lib/packages.mjs +25 -190
  79. package/lib/provider-binding.mjs +4 -2
  80. package/lib/provider-reasons.mjs +3 -68
  81. package/lib/resolve.mjs +204 -68
  82. package/lib/schedule.mjs +97 -272
  83. package/lib/servers.mjs +13 -13
  84. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  85. package/lib/tree-copy.mjs +44 -0
  86. package/lib/workspace.mjs +125 -20
  87. package/package-catalog.json +6 -6
  88. package/package.json +1 -1
  89. package/skills/integration-authoring/SKILL.md +48 -40
  90. package/skills/oats-getting-started/SKILL.md +105 -110
  91. package/skills/oats-support/SKILL.md +2 -2
  92. package/skills/soul-craft/SKILL.md +13 -6
  93. package/bin/oats-pi-sdk-host.mjs +0 -17
  94. package/docs/2026-09-03-architecture-proposal.md +0 -642
  95. package/docs/artifact-approvals.schema.json +0 -7
  96. package/docs/captured-invocation-context.schema.json +0 -7
  97. package/docs/captured-resolution.schema.json +0 -7
  98. package/docs/design/package-engine-contract.md +0 -813
  99. package/docs/design/package-runtime-api.md +0 -588
  100. package/docs/desktop-succession.md +0 -57
  101. package/docs/execution-capsule.schema.json +0 -108
  102. package/docs/first-team-demo.md +0 -92
  103. package/docs/knowledge-migration.md +0 -147
  104. package/docs/migration-from-oas.md +0 -103
  105. package/docs/oats-config.schema.json +0 -172
  106. package/docs/oats-lock-v3.schema.json +0 -7
  107. package/docs/oats-lock.schema.json +0 -175
  108. package/docs/operating-team-migration.md +0 -470
  109. package/docs/portable.schema.json +0 -2512
  110. package/docs/provider-check-input.schema.json +0 -7
  111. package/docs/rebuild-to-v2.md +0 -511
  112. package/docs/workspace-adoption.md +0 -74
  113. package/injects/framework-workspace.md +0 -7
  114. package/injects/local-soul.md +0 -19
  115. package/injects/oats-portable.md +0 -20
  116. package/injects/oats.md +0 -11
  117. package/injects/portable-instance-boundary.md +0 -39
  118. package/injects/portable-work-directory.md +0 -29
  119. package/lib/artifact-approvals.mjs +0 -120
  120. package/lib/artifact-tree.mjs +0 -141
  121. package/lib/capability-artifacts.mjs +0 -179
  122. package/lib/capability-execution.mjs +0 -15
  123. package/lib/capability-inputs.mjs +0 -39
  124. package/lib/capability-provenance.mjs +0 -231
  125. package/lib/captured-action-shape.mjs +0 -21
  126. package/lib/captured-admission-shape.mjs +0 -20
  127. package/lib/captured-binding-file.mjs +0 -36
  128. package/lib/captured-dispatch.mjs +0 -66
  129. package/lib/captured-instance-index.mjs +0 -277
  130. package/lib/captured-invocation-context.mjs +0 -130
  131. package/lib/captured-launch-request.mjs +0 -66
  132. package/lib/captured-operation-process.mjs +0 -15
  133. package/lib/captured-pi-custody.mjs +0 -29
  134. package/lib/captured-pi-host.mjs +0 -167
  135. package/lib/captured-pi-outcome.mjs +0 -172
  136. package/lib/captured-resolutions.mjs +0 -275
  137. package/lib/captured-scaffold.mjs +0 -87
  138. package/lib/captured-selector.mjs +0 -28
  139. package/lib/captured-session-backend.mjs +0 -52
  140. package/lib/captured-source-receipt-file.mjs +0 -72
  141. package/lib/helper-injection-policy.mjs +0 -104
  142. package/lib/legacy-lock-codec.mjs +0 -106
  143. package/lib/manifest-settings.mjs +0 -84
  144. package/lib/package-closure.mjs +0 -48
  145. package/lib/package-materialization.mjs +0 -83
  146. package/lib/pi-sdk-host.mjs +0 -229
  147. package/lib/portable-artifacts.mjs +0 -115
  148. package/lib/portable-choices.mjs +0 -82
  149. package/lib/portable-composition.mjs +0 -136
  150. package/lib/portable-digest.mjs +0 -105
  151. package/lib/portable-identity.mjs +0 -40
  152. package/lib/portable-lock.mjs +0 -117
  153. package/lib/portable-onboarding-request.mjs +0 -49
  154. package/lib/portable-onboarding.mjs +0 -256
  155. package/lib/portable-package-preparation.mjs +0 -188
  156. package/lib/portable-policy.mjs +0 -44
  157. package/lib/portable-soul.mjs +0 -42
  158. package/lib/portable-state.mjs +0 -80
  159. package/lib/prepare-composition.mjs +0 -170
  160. package/lib/prepared-bindings.mjs +0 -92
  161. package/lib/prepared-resources.mjs +0 -127
  162. package/lib/provider-binding-broker.mjs +0 -65
  163. package/lib/provider-binding-wire.mjs +0 -116
  164. package/lib/readiness.mjs +0 -225
  165. package/lib/repository-observation.mjs +0 -226
  166. package/lib/resolution-shape.mjs +0 -393
  167. package/lib/schedule-capsule.mjs +0 -206
  168. package/lib/soul-constraints.mjs +0 -40
  169. package/lib/source-projection.mjs +0 -84
  170. package/lib/source-spec.mjs +0 -189
  171. package/lib/workspace-definition.mjs +0 -126
  172. package/lib/workspace-discovery.mjs +0 -146
  173. package/skills/oats/SKILL.md +0 -162
  174. package/skills/oats-config/SKILL.md +0 -164
  175. package/skills/oats-packages/SKILL.md +0 -184
  176. package/skills/oats-portable/SKILL.md +0 -115
  177. package/skills/oats-portable-artifacts/SKILL.md +0 -63
@@ -7,8 +7,8 @@
7
7
  * oats-aweb retire gracefully self-delete it (BEFORE the home dir is removed)
8
8
  * oats-aweb roster list the aweb team's members — the cross-machine directory
9
9
  * of live instances (alias = instance name) and humans
10
- * oats-aweb setup guided onboarding: check the aw CLI, initialize the team
11
- * scope's aweb workspace, create/join the team
10
+ * oats-aweb setup guided onboarding: check the aw CLI, initialize the
11
+ * messaging root with aw init / aw team join, verify team
12
12
  *
13
13
  * Env contract (set by the kernel):
14
14
  * OATS_EVENT spawn|retire
@@ -43,7 +43,8 @@ import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
43
43
  import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
44
44
  import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
45
45
  import { runCapturedNative } from "../lib/captured-native.mjs";
46
- import { parseBindingJson } from "../lib/binding-wire.mjs";
46
+ import { CUSTODY_ATTACH_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
+ import { custodyPreflight } from "../lib/grant-custody.mjs";
47
48
 
48
49
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
49
50
  * names and invite tokens all flow through here; quoting them correctly is a
@@ -93,14 +94,20 @@ const parseSecretJson = (text, what) => {
93
94
  * would read as "aw is missing" on every such host. */
94
95
  /** The installed aw's version from `aw version` ("aw 1.36.1 ..."), or
95
96
  * undefined when it cannot be read; compared as numeric triples. */
97
+ function awVersionTriple() {
98
+ try { return /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return undefined; }
99
+ }
96
100
  function awAtLeast(floor) {
97
- let v;
98
- try { v = /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return false; }
101
+ const v = awVersionTriple();
99
102
  if (!v) return false;
100
103
  const a = v.slice(1, 4).map(Number), b = floor.split(".").map(Number);
101
104
  for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; }
102
105
  return true;
103
106
  }
107
+ function awVersionLabel() {
108
+ const v = awVersionTriple();
109
+ return v ? v.slice(1, 4).join(".") : "unknown";
110
+ }
104
111
 
105
112
  function onPath(cmd) {
106
113
  for (const dir of String(process.env.PATH || "").split(delimiter)) {
@@ -152,6 +159,9 @@ try {
152
159
  const event = process.env.OATS_EVENT || process.argv[2];
153
160
  const instance = process.env.OATS_INSTANCE;
154
161
  const home = process.env.OATS_HOME || process.cwd();
162
+ const AWEB_ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
163
+ const AWEB_ALIAS_RULE = "invalid alias: aweb aliases must be 1-64 characters, start with a letter or digit, and then contain only letters, digits, '-' or '_'";
164
+ const ALIAS_REUSE_REMEDY = "spawn with a different --name (kernels 0.26.0+) or a different --purpose";
155
165
  // Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
156
166
  // delivery: "channel" (default) keeps the native channel packages waking the
157
167
  // instance; "session" hands delivery to the host wake broker (aweb-abil):
@@ -166,6 +176,7 @@ const deliveryMode = (() => {
166
176
  const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
167
177
  const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
168
178
  if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
179
+ 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`);
169
180
  const payloadTeam = () => {
170
181
  const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
171
182
  const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
@@ -283,8 +294,23 @@ function wakeRegister(instanceHome, identityHome) {
283
294
  function wakeDeregister(instanceHome) {
284
295
  try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
285
296
  }
286
- const DEFAULT_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send"];
297
+ // The first aw release whose grant subtree accepts `aw id grant mint --team
298
+ // <team-id>` (and the full normal/reviewer scope lists): aw 1.36.2, paired with
299
+ // aweb server 1.27.5 for complete custody operations. aw 1.36.1 has neither.
300
+ const GRANT_TEAM_FLAG_MIN = "1.36.2";
301
+ const NORMAL_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send", "events.read", "coord.read", "coord.write", "presence.write", "contacts.read", "contacts.write"];
302
+ const REVIEWER_GRANT_SCOPES = ["mail.read", "chat.read", "events.read", "coord.read", "presence.write"];
287
303
  const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
304
+ const grantProfile = () => identitySettings.profile === undefined || identitySettings.profile === null || identitySettings.profile === "" ? "normal" : String(identitySettings.profile);
305
+ function grantScopes() {
306
+ if (Array.isArray(identitySettings.scopes) && identitySettings.scopes.length) return identitySettings.scopes.map(String);
307
+ const profile = grantProfile();
308
+ if (profile === "normal") return [...NORMAL_GRANT_SCOPES];
309
+ if (profile === "reviewer") return [...REVIEWER_GRANT_SCOPES];
310
+ fatal(`identity.profile must be one of "normal" or "reviewer" (got ${JSON.stringify(identitySettings.profile)})`);
311
+ }
312
+ const grantE2eeRequired = () => identitySettings.e2ee !== false;
313
+ const grantRenewMode = () => identitySettings.renew === undefined || identitySettings.renew === null || identitySettings.renew === "" ? "off" : String(identitySettings.renew);
288
314
  function resolveResidentCustody(name) {
289
315
  if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
290
316
  const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
@@ -314,6 +340,21 @@ function revokeGrant(custody, grantId) {
314
340
  throw e;
315
341
  }
316
342
  }
343
+ function grantYamlNestedScalar(text, sectionName, key) {
344
+ const lines = String(text ?? "").split(/\r?\n/);
345
+ let inSection = false, baseIndent = 0;
346
+ for (const line of lines) {
347
+ const section = new RegExp(`^(\\s*)${sectionName}:\\s*(?:#.*)?$`).exec(line);
348
+ if (section) { inSection = true; baseIndent = section[1].length; continue; }
349
+ if (inSection) {
350
+ const ind = /^(\s*)/.exec(line)?.[1].length || 0;
351
+ if (line.trim() && ind <= baseIndent) inSection = false;
352
+ const value = new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`).exec(line);
353
+ if (value) return value[1].trim();
354
+ }
355
+ }
356
+ return undefined;
357
+ }
317
358
  function recoverGrantHome(grantHome) {
318
359
  try {
319
360
  const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
@@ -321,9 +362,120 @@ function recoverGrantHome(grantHome) {
321
362
  const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
322
363
  return m ? m[1].trim() : undefined;
323
364
  };
324
- return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at") };
365
+ return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at"), subjectAlias: grantYamlNestedScalar(text, "subject", "alias"), custodySocket: grantYamlCustodySocket(text) };
325
366
  } catch { return {}; }
326
367
  }
368
+ function readGrantYamlCustodySocket(grantHome) {
369
+ const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
370
+ return grantYamlCustodySocket(text);
371
+ }
372
+ function verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team }) {
373
+ const recorded = readGrantYamlCustodySocket(grantHome);
374
+ if (!recorded) throw new Error(`grant.yaml custody.socket_path is missing`);
375
+ if (recorded !== custodySocket) throw new Error(`grant.yaml custody.socket_path ${recorded} differs from preflight ${custodySocket}`);
376
+ const raw = run(["aw", "custody", "status", "--json"], grantHome, 20000, { env: { AWEB_IDENTITY_HOME: grantHome }, secretSafe: true });
377
+ const status = parseAwJson(raw, "aw custody status");
378
+ if (String(status.status || "") !== "running") throw new Error(`grant custody status is ${status.status || "unknown"}, not running`);
379
+ if (status.socket_path !== custodySocket) throw new Error(`grant custody status socket_path ${status.socket_path || "<missing>"} differs from preflight ${custodySocket}`);
380
+ if (status.resident?.alias !== alias) throw new Error(`grant custody resident alias ${status.resident?.alias || "<missing>"} differs from ${alias}`);
381
+ const teamRow = (Array.isArray(status.teams) ? status.teams : []).find((t) => t && (t.team_id || t.id) === team);
382
+ if (!teamRow || teamRow.ready !== true) throw new Error(`grant custody team ${team} is not ready`);
383
+ return status;
384
+ }
385
+ function preflightCustodySocket(preflight) {
386
+ return typeof preflight.status?.socket_path === "string" && preflight.status.socket_path.trim() ? preflight.status.socket_path.trim() : undefined;
387
+ }
388
+ function requirePreflightCustodySocket(preflight) {
389
+ const socket = preflightCustodySocket(preflight);
390
+ if (!socket) fatal("custody preflight reported no socket_path, so the grant cannot be attached to custody");
391
+ return socket;
392
+ }
393
+ function grantHomeDirs() {
394
+ try {
395
+ return readdirSync(home)
396
+ .filter((name) => name === ".aweb-identity" || /^\.aweb-identity-\d+$/.test(name))
397
+ .map((name) => join(home, name))
398
+ .filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
399
+ } catch { return []; }
400
+ }
401
+ function removeGrantHomes() { for (const p of grantHomeDirs()) { try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } }
402
+ function newestGrantHome() {
403
+ const dirs = grantHomeDirs().map((p) => {
404
+ try { return { path: p, mtime: statSync(p).mtimeMs }; } catch { return { path: p, mtime: 0 }; }
405
+ }).sort((a, b) => b.mtime - a.mtime || b.path.localeCompare(a.path));
406
+ return dirs[0]?.path || join(home, ".aweb-identity");
407
+ }
408
+ const priorGrantHome = (meta = {}) => typeof meta.identity?.grant?.home === "string" && meta.identity.grant.home ? meta.identity.grant.home : newestGrantHome();
409
+ function retainedLaunchOutput(meta = {}, identityHome = priorGrantHome(meta)) {
410
+ const delivery = meta.delivery || deliveryMode;
411
+ const env = {};
412
+ if (delivery === "session") env.AWEB_DELIVERY = "session";
413
+ if (meta.identity?.mode === "global" && meta.identity?.grant?.id) env.AWEB_IDENTITY_HOME = identityHome;
414
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && delivery === "channel" ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" } : undefined;
415
+ return { ...(Object.keys(env).length ? { env } : {}), ...(launch ? { launch } : {}) };
416
+ }
417
+ function grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }) {
418
+ 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"];
419
+ }
420
+ function validateMintedGrant(minted, grantHome) {
421
+ const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
422
+ const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
423
+ const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
424
+ const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
425
+ if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
426
+ if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
427
+ return { minted, grantId, expiresAt, mintedTeam, alias: typeof minted.alias === "string" && minted.alias ? minted.alias : undefined, address: typeof minted.address === "string" && minted.address ? minted.address : null };
428
+ }
429
+ function parseMintedGrant(raw, grantHome) { return validateMintedGrant(parseAwJson(raw, "aw id grant mint"), grantHome); }
430
+ function globalGrantRenew() {
431
+ const mode = grantRenewMode();
432
+ const oldMeta = JSON.parse(process.env.OATS_META || "{}");
433
+ if (mode === "off") out(retainedLaunchOutput(oldMeta));
434
+ if (mode !== "launch") fatal(`identity.renew must be "off" or "launch" (got ${JSON.stringify(identitySettings.renew)})`);
435
+ if (oldMeta.identity?.mode !== "global" || !oldMeta.identity?.grant?.id) out(retainedLaunchOutput(oldMeta));
436
+ const resident = String(identitySettings.resident || oldMeta.identity.resident || "");
437
+ const custody = resolveResidentCustody(resident);
438
+ const team = payloadTeam().team || oldMeta.identity.team;
439
+ if (!team) fatal("identity.renew launch needs the prior grant team or settings.oats.aweb.team");
440
+ const scopes = grantScopes();
441
+ const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
442
+ const oldHome = priorGrantHome(oldMeta);
443
+ let preflight;
444
+ try { preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), fatalOnError: false, runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal }); }
445
+ 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}` }); }
446
+ const custodySocket = preflightCustodySocket(preflight);
447
+ 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}` });
448
+ 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}` });
449
+ let stamp = Math.floor(Date.now() / 1000);
450
+ let grantHome = join(home, `.aweb-identity-${stamp}`);
451
+ while (existsSync(grantHome)) grantHome = join(home, `.aweb-identity-${++stamp}`);
452
+ let parsed;
453
+ try {
454
+ parsed = parseMintedGrant(run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] }), grantHome);
455
+ } catch (e) {
456
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
457
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal mint failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
458
+ }
459
+ const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = parsed;
460
+ const recovered = recoverGrantHome(grantHome);
461
+ const alias = recovered.subjectAlias || mintedAlias || oldMeta.identity.alias || resident;
462
+ const newMeta = { ...oldMeta, delivery: oldMeta.delivery || deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address: address || oldMeta.identity.address || null, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
463
+ if (mintedTeam !== team) {
464
+ try { revokeGrant(custody, grantId); } catch { /* minted mismatch expires by TTL if revoke fails */ }
465
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
466
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal minted grant team ${mintedTeam} differs from ${team}; keeping previous grant ${oldMeta.identity.grant.id}` });
467
+ }
468
+ try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias: newMeta.identity.alias, team: mintedTeam }); }
469
+ catch (e) {
470
+ try { revokeGrant(custody, grantId); } catch { /* new grant expires by TTL if revoke fails */ }
471
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
472
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal grant ${grantId} custody attachment failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
473
+ }
474
+ let warning;
475
+ try { revokeGrant(custody, oldMeta.identity.grant.id); }
476
+ catch (e) { warning = `oats-aweb: previous grant ${oldMeta.identity.grant.id} was not revoked (${e.message || e}); new grant ${grantId} is kept and the previous grant still expires at ${oldMeta.identity.grant.expiresAt || "its TTL"}`; }
477
+ out({ meta: newMeta, ...retainedLaunchOutput(newMeta, grantHome), ...(warning ? { warning } : {}) });
478
+ }
327
479
  function globalGrantSpawn() {
328
480
  const { team, payload, env: envTeam } = payloadTeam();
329
481
  if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
@@ -331,37 +483,40 @@ function globalGrantSpawn() {
331
483
  const custody = resolveResidentCustody(resident);
332
484
  const grantHome = join(home, ".aweb-identity");
333
485
  if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
334
- const scopes = Array.isArray(identitySettings.scopes) && identitySettings.scopes.length ? identitySettings.scopes.map(String) : DEFAULT_GRANT_SCOPES;
486
+ const scopes = grantScopes();
335
487
  const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
488
+ const preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal });
489
+ const custodySocket = requirePreflightCustodySocket(preflight);
490
+ if (!awAtLeast(CUSTODY_ATTACH_MIN)) fatal(`aw ${awVersionLabel()} cannot attach a grant to custody (--custody-socket); grants need aw >= ${CUSTODY_ATTACH_MIN}`);
336
491
  let meta;
337
492
  const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
338
493
  const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
339
494
  try {
340
- const raw = run(["aw", "id", "grant", "mint", "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
495
+ const raw = run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
341
496
  let minted;
342
- try { minted = parseAwJson(raw, "aw id grant mint"); }
497
+ try { minted = parseMintedGrant(raw, grantHome).minted; }
343
498
  catch (parseError) {
344
499
  const recovered = recoverGrantHome(grantHome);
345
500
  if (recovered.grantId) {
346
- meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes } }) };
501
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes, home: grantHome } }) };
347
502
  try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
348
503
  catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
349
504
  }
350
505
  throw parseError;
351
506
  }
352
- const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
353
- const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
354
- const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
355
- const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
356
- if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
357
- if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
358
- const alias = typeof minted.alias === "string" && minted.alias ? minted.alias : resident;
359
- const address = typeof minted.address === "string" && minted.address ? minted.address : null;
360
- meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes } }) };
507
+ const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = validateMintedGrant(minted, grantHome);
508
+ const recovered = recoverGrantHome(grantHome);
509
+ const alias = recovered.subjectAlias || mintedAlias || resident;
510
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
361
511
  if (mintedTeam !== team) {
362
512
  try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
363
513
  catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
364
514
  }
515
+ try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team: mintedTeam }); }
516
+ catch (e) {
517
+ try { revokeGrant(custody, grantId); failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; the grant was revoked and nothing was kept`); }
518
+ catch (revokeError) { failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
519
+ }
365
520
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
366
521
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
367
522
  : undefined;
@@ -373,15 +528,16 @@ function globalGrantSpawn() {
373
528
  catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
374
529
  }
375
530
  }
376
- const warnings = [];
531
+ const warnings = [...preflight.warnings];
377
532
  if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
533
+ const e2eeBrief = preflight.warnings.length ? ` Warning: ${preflight.warnings.join(" ")}` : "";
378
534
  const deliveryBrief = deliveryMode === "session"
379
535
  ? ` 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.`
380
536
  : "";
381
537
  out({
382
538
  meta,
383
539
  env,
384
- brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run.${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
540
+ brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run; your grant home is attached to the resident's custody service. If a message you sent shows unverified at the receiver, report it, do not retry.${e2eeBrief}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
385
541
  ...(launch ? { launch } : {}),
386
542
  ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
387
543
  });
@@ -398,8 +554,10 @@ function globalGrantRetire(meta) {
398
554
  const custody = resolveResidentCustody(resident);
399
555
  try {
400
556
  revokeGrant(custody, id);
557
+ removeGrantHomes();
401
558
  out({ meta: { retired: true, identityRevoked: true, grant: id } });
402
559
  } catch (e) {
560
+ removeGrantHomes();
403
561
  out({ meta: { retired: false, reason: "grant-revoke-failed", grant: id }, warning: `oats-aweb: grant ${id} was not revoked (${e.message || e}); it still expires at ${meta.identity?.grant?.expiresAt || "its TTL"}` }, 1);
404
562
  }
405
563
  }
@@ -407,7 +565,7 @@ const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json")
407
565
  /** The alias a home's .aw/workspace.yaml records under memberships (indented),
408
566
  * or undefined. Read only when the hook has no alias of its own. */
409
567
  const workspaceAliasOf = (homeDir) => {
410
- try { const m = readFileSync(join(homeDir, ".aw", "workspace.yaml"), "utf8").match(/^\s*alias:\s*["']?([a-z0-9][a-z0-9._-]{0,127})["']?\s*$/mi); return m ? m[1] : undefined; }
568
+ try { const m = readFileSync(join(homeDir, ".aw", "workspace.yaml"), "utf8").match(/^\s*alias:\s*["']?([a-z0-9][a-z0-9_-]{0,63})["']?\s*$/mi); return m ? m[1] : undefined; }
411
569
  catch { return undefined; }
412
570
  };
413
571
  /** A join that the CLI reported as failed (or that this hook killed on
@@ -513,7 +671,7 @@ function retainedSeatSpawn(source, takeOver) {
513
671
  if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
514
672
  if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
515
673
  const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
516
- if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
674
+ if (!AWEB_ALIAS_RE.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
517
675
  const alias = aliasRaw;
518
676
  if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
519
677
  writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
@@ -541,7 +699,10 @@ function retainedSeatSpawn(source, takeOver) {
541
699
  }
542
700
  }
543
701
 
544
- if (event === "spawn") {
702
+ if (event === "launch") {
703
+ if (identityMode === "global" || grantRenewMode() === "launch") globalGrantRenew();
704
+ out(retainedLaunchOutput(JSON.parse(process.env.OATS_META || "{}")));
705
+ } else if (event === "spawn") {
545
706
  if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
546
707
  if (identityMode === "global") globalGrantSpawn();
547
708
  if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
@@ -594,7 +755,7 @@ if (event === "spawn") {
594
755
  // re-spawn under the same name is refused by AWID. Say that, and the
595
756
  // remedy, instead of relaying a bare join error.
596
757
  if (e.aliasConflict) {
597
- fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — spawn with a fresh --purpose instead`);
758
+ fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — ${ALIAS_REUSE_REMEDY}`);
598
759
  }
599
760
  throw e;
600
761
  }
@@ -607,7 +768,7 @@ if (event === "spawn") {
607
768
  // token; otherwise fall back to what WE asked for, which is always known.
608
769
  const clean = (v) => (typeof v === "string" && v.trim() && !v.includes(inv.token) ? v.trim() : undefined);
609
770
  const joined = {
610
- alias: (() => { const a = clean(raw.alias); return a && /^[a-z0-9][a-z0-9._-]{0,127}$/i.test(a) ? a : instance; })(),
771
+ alias: (() => { const a = clean(raw.alias); return a && AWEB_ALIAS_RE.test(a) ? a : instance; })(),
611
772
  // Team ids are "<name>:<domain>"; anything else is not one, and the
612
773
  // requested team is the honest fallback.
613
774
  team_id: (() => { const t = clean(raw.team_id); return t && /^[^\s:]+:[^\s:]+$/.test(t) ? t : team; })(),
@@ -656,7 +817,7 @@ if (event === "spawn") {
656
817
  // A retained seat: release the lock and leave the identity alone. Never
657
818
  // aw workspace delete (it would soft-delete the standing identity's row)
658
819
  // and never team retire; the source .aw stays until a human removes it.
659
- if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
820
+ if (meta.delivery === "session" && (meta.retained || meta.identity?.mode !== "global")) { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
660
821
  if (meta.retained) {
661
822
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
662
823
  out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
@@ -688,14 +849,14 @@ if (event === "spawn") {
688
849
  // aw 1.36.1 prints the cause as alias_released_reason (workspace.go,
689
850
  // workspace_self_retire.go); `reason` is tolerated for a later rename.
690
851
  const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
691
- out({ meta: { retired: true, aliasReusable: released, aliasReason: reason }, ...(released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a fresh --purpose until it is` }) });
852
+ out({ meta: { retired: true, aliasReusable: released, aliasReason: reason }, ...(released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a different --name (kernels 0.26.0+) or a different --purpose until it is` }) });
692
853
  }
693
854
  run(["aw", "workspace", "delete", meta.alias], home);
694
855
  // Honest: the workspace row is deleted, but a hosted local member cannot
695
856
  // revoke its own AWID certificate (aweb-abim), so the alias is NOT
696
857
  // reusable. retired stays true because the cleanup is as complete as the
697
858
  // platform allows; the field and the line carry the truth.
698
- out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a fresh --purpose` });
859
+ out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a different --name (kernels 0.26.0+) or a different --purpose` });
699
860
  } catch (e) {
700
861
  // Exit nonzero: during a required-hook rollback this is the signal that
701
862
  // compensation did NOT complete, so the spawn is not reported as cleanly
@@ -721,80 +882,105 @@ if (event === "spawn") {
721
882
  console.log("\nAliases minted by OATS are instance names; message one with `aw mail send --to <alias> --subject \"...\" --body \"...\"`.");
722
883
  process.exit(0);
723
884
  } else if (event === "setup") {
724
- // Guided onboarding — idempotent, prints what it finds and the one next step.
725
- if (isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined) {
726
- const scope = process.env.OATS_TEAM_SCOPE || process.cwd();
727
- const teamName = process.env.OATS_TEAM_NAME;
728
- const teamId = process.env.OATS_TEAM_ID;
729
- console.log(`aweb onboarding — team scope: ${scope}${teamName ? `, config team: ${teamName}${teamId ? ` (${teamId})` : ""}` : ""}\n`);
730
- if (!teamName) {
731
- console.log("1. Declare your team in the deployment scope's oats-config.yaml first:");
732
- console.log(" team:\n name: <your-team>\n then re-run `oats aweb setup` from there.");
733
- process.exit(0);
734
- }
735
- if (!existsSync(join(scope, ".aw"))) {
736
- console.log(`No aweb workspace at the team scope yet. Initialize it (interactive — creates or connects an aweb account):`);
737
- console.log(` cd ${scope} && aw init`);
738
- console.log(" First time on aweb? `aw init` walks you through creating a hosted aweb.ai account.");
739
- console.log(" Own your domain? Use `aw init --byod` (see the aweb-team-membership skill).");
740
- process.exit(0);
741
- }
742
- let teams = { memberships: [] };
743
- try { teams = JSON.parse(run(["aw", "team", "list", "--json"], scope)); } catch { /* fall through */ }
744
- const want = teamId || teamName;
745
- const match = teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`));
746
- if (match) {
747
- console.log(`✓ aweb workspace initialized and member of ${match}.`);
748
- if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
749
- console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
750
- console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
751
- } else {
752
- console.log(`Workspace initialized, but no membership matching "${want}".`);
753
- console.log(` Create the team: cd ${scope} && aw team create ${teamName}`);
754
- console.log(" Or join an existing one: get an invite token from a member, then `aw team join <token>`");
755
- console.log(" (details: aweb-team-membership skill)");
756
- }
757
- process.exit(0);
885
+ // Guided onboarding — idempotent, prints what it finds and can run one
886
+ // existing aw primitive when the operator supplies the needed authority.
887
+ const args = process.argv.slice(3).filter((arg) => arg !== "--json");
888
+ const usage = "usage: oats aweb setup [--username <hosted-user> | --invite <token>]";
889
+ let username, invite;
890
+ for (let i = 0; i < args.length; i++) {
891
+ const arg = args[i];
892
+ if (arg === "--username" && args[i + 1]) { username = args[++i]; continue; }
893
+ if (arg.startsWith("--username=") && arg.length > "--username=".length) { username = arg.slice("--username=".length); continue; }
894
+ if (arg === "--invite" && args[i + 1]) { invite = args[++i]; continue; }
895
+ if (arg.startsWith("--invite=") && arg.length > "--invite=".length) { invite = arg.slice("--invite=".length); continue; }
896
+ console.error(`oats aweb setup: ${usage}`);
897
+ process.exit(2);
758
898
  }
899
+ const apiKey = !!process.env.AWEB_API_KEY;
900
+ const actions = [username ? "--username" : null, invite ? "--invite" : null, apiKey ? "AWEB_API_KEY" : null].filter(Boolean);
901
+ if (actions.length > 1) { console.error(`oats aweb setup: choose exactly one onboarding authority (${actions.join(", ")})\n${usage}`); process.exit(2); }
902
+
759
903
  const resolvedTeam = payloadTeam();
760
904
  const teamName = resolvedTeam.team;
761
905
  const teamId = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : process.env.OATS_TEAM_ID;
762
906
  const candidate = rootSettingCandidate(teamName);
763
- const scope = candidate?.root ? resolve(candidate.root) : process.cwd();
764
- console.log(`aweb onboarding — messaging root: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
765
- if (!teamName) {
766
- console.log(`1. Choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
767
- console.log(" The workspace file's `messaging:` / `messaging.byTeam.<label>.team` payload is portable; host-specific overrides belong in `settings.oats.aweb.team`.");
768
- process.exit(0);
769
- }
907
+ const scope = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined
908
+ ? resolve(process.env.OATS_TEAM_SCOPE || process.cwd())
909
+ : (candidate?.root ? resolve(candidate.root) : process.cwd());
910
+ const classic = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined;
911
+ console.log(`aweb onboarding — ${classic ? "team scope" : "messaging root"}: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
770
912
  if (!isAbsolute(scope)) {
771
913
  console.log(`${candidate?.key || "settings.oats.aweb.root"} must be an absolute directory whose .aw is the aweb minting root.`);
772
914
  process.exit(0);
773
915
  }
774
- if (!existsSync(join(scope, ".aw"))) {
775
- console.log(`No aweb workspace at the messaging root yet (${candidate?.key || "settings.oats.aweb.root"}). Initialize it (interactive — creates or connects an aweb account):`);
776
- console.log(` cd ${scope} && aw init`);
777
- console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run `oats aweb setup`.");
778
- console.log(" First time on aweb? `aw init` walks you through creating a hosted aweb.ai account.");
779
- console.log(" Own your domain? Use `aw init --byod` (see the aweb-team-membership skill).");
780
- process.exit(0);
781
- }
782
- let teams = { memberships: [] };
783
- try { teams = JSON.parse(run(["aw", "team", "list", "--json"], scope)); } catch { /* fall through */ }
916
+
784
917
  const want = teamId || teamName;
785
- const match = teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`));
786
- if (match) {
787
- console.log(`✓ aweb workspace initialized and member of ${match}.`);
788
- if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
789
- console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
790
- console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
791
- } else {
792
- console.log(`Workspace initialized, but no membership matching "${want}".`);
793
- console.log(` Create the team: cd ${scope} && aw team create ${teamName}`);
794
- console.log(" Or join an existing one: get an invite token from a member, then `aw team join <token>`");
795
- console.log(" (details: aweb-team-membership skill)");
918
+ const defaultTeamForUsername = username ? `default:${username}.aweb.ai` : undefined;
919
+ const readTeams = () => {
920
+ try { return parseAwJson(run(["aw", "team", "list", "--json"], scope), "aw team list"); }
921
+ catch { return { memberships: [] }; }
922
+ };
923
+ const matchingTeam = (teams) => want ? teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`)) : undefined;
924
+ const printVerdict = (teams) => {
925
+ const match = matchingTeam(teams);
926
+ if (match) {
927
+ console.log(`readiness: ready`);
928
+ console.log(`✓ aweb workspace initialized and member of ${match}.`);
929
+ if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
930
+ console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
931
+ console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
932
+ return;
933
+ }
934
+ console.log(`readiness: needs-configuration`);
935
+ if (!want) {
936
+ console.log(` no team: ${teamConfigRemedy()}`);
937
+ const active = teams.active_team || teamIdsOf(teams)[0];
938
+ 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.`);
939
+ else if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; map the workspace team to that id if this is the intended team.`);
940
+ return;
941
+ }
942
+ console.log(` Workspace initialized, but no membership matching "${want}".`);
943
+ if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; set messaging.byTeam.<label>.team or settings.oats.aweb.team to that id, then re-run setup.`);
944
+ console.log(" Existing team path: ask a member for an invite token, then run `oats aweb setup --invite <token>` (uses `aw team join <token>` at the root).");
945
+ console.log(" Team API-key path: set AWEB_API_KEY in the environment and run `oats aweb setup` (uses `aw init` at the root; the key is never printed).");
946
+ console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --username <u>` and creates default:<u>.aweb.ai).");
947
+ };
948
+
949
+ try {
950
+ const hasRoot = existsSync(join(scope, ".aw"));
951
+ if (!hasRoot && !actions.length) {
952
+ console.log(`No aweb workspace at the ${classic ? "team scope" : "messaging root"} yet (${candidate?.key || "settings.oats.aweb.root"}).`);
953
+ if (!want) console.log(` Also choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
954
+ console.log(" Choose one guided setup path:");
955
+ console.log(" oats aweb setup --username <u> # runs `aw init --username <u>` and creates default:<u>.aweb.ai");
956
+ console.log(" AWEB_API_KEY=<key> oats aweb setup # runs `aw init` for the hosted team behind the key");
957
+ console.log(" oats aweb setup --invite <token> # runs `aw team join <token>` from an existing-team invite");
958
+ console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run setup.");
959
+ console.log(" Own your domain? Use the aweb-team-membership skill for BYOT flows.");
960
+ process.exit(0);
961
+ }
962
+ let teams = hasRoot ? readTeams() : { memberships: [] };
963
+ if (hasRoot && !matchingTeam(teams) && !actions.length) { printVerdict(teams); process.exit(0); }
964
+ if (!hasRoot || !matchingTeam(teams)) {
965
+ if (actions.length) mkdirSync(scope, { recursive: true });
966
+ if (username) {
967
+ console.log(`Running aw init --username <u> at ${scope} (username withheld from repeated logs).`);
968
+ run(["aw", "init", "--username", username], scope, 120000, { secrets: [username], unsetEnv: ["AWEB_API_KEY"] });
969
+ } else if (invite) {
970
+ console.log(`Running aw team join <token> at ${scope} (token withheld).`);
971
+ run(["aw", "team", "join", invite], scope, 120000, { secrets: [invite], secretSafe: true });
972
+ } else if (apiKey) {
973
+ console.log(`Running aw init at ${scope} with AWEB_API_KEY from the environment (key withheld).`);
974
+ run(["aw", "init"], scope, 120000, { secretSafe: true });
975
+ }
976
+ teams = readTeams();
977
+ }
978
+ printVerdict(teams);
979
+ process.exit(0);
980
+ } catch (e) {
981
+ console.error(`oats aweb setup: ${e.message || e}`);
982
+ process.exit(1);
796
983
  }
797
- process.exit(0);
798
984
  } else {
799
985
  warn(`unknown event "${event}" (expected spawn|retire)`);
800
986
  }
@@ -8,8 +8,13 @@ Some instances serve a resident global identity through an expiring session
8
8
  grant instead of holding their own root keys. In that mode, your TASK.md and
9
9
  `instance.json` identify the resident alias, scopes, and expiry; root keys are
10
10
  not in your home, and mint/revoke/join/identity-lifecycle commands are not yours
11
- to run. If `aw mail` or `aw chat` says the grant expired or was revoked, stop
12
- messaging and report the condition to your coordinator/human.
11
+ to run. Your grant home is attached to the resident's custody service; if a
12
+ message you sent shows unverified at the receiver, the attachment is missing:
13
+ report it, do not retry. If `aw mail` or `aw chat` reports a terminal grant condition —
14
+ `grant_expired`, `grant_revoked`, `grant_subject_inactive`,
15
+ `grant_issuer_revoked`, or `grant_freshness_unavailable` — report the exact
16
+ condition to your coordinator/human and stop using messaging. The host restarts
17
+ you with a fresh grant when renewal is available.
13
18
 
14
19
  **Load the skills at the right moments — do not work from memory:**
15
20