@awebai/oats 0.36.1 → 0.38.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.
package/bin/oats.mjs CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
37
37
  classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
38
38
  import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings, memberRowByKey } from "../lib/workspace.mjs";
39
- import { migrationProblems, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "../lib/teams.mjs";
39
+ import { discoveredTeamKeys, isTeamRefusal, localTeamsClosedProblem, recordedTeams, reportRows, soulKeyOf, soulTeams, teamKeyOf, teamModel } from "../lib/teams.mjs";
40
40
  import { launchLayers } from "../lib/launch-preference.mjs";
41
41
  import { parseConfigData } from "../lib/config-data.mjs";
42
42
  import * as remoteModule from "../lib/remote.mjs";
@@ -221,17 +221,18 @@ async function doctorComposition(ctx, soulName, ws, bail) {
221
221
  } finally { for (const c of cleanups) { try { c(); } catch { /* best effort: temporary copies only */ } } }
222
222
  }
223
223
 
224
- /** team-model-3-migration in doctor (0.36.x), OFFLINE like the rest of doctor: oats-local.yaml, and for its
225
- * local teams the workspace file this machine's parsed cache holds (cachedWorkspace: no git process, no
226
- * network). Without that file, whether local teams need `localTeams: true` is said to be unchecked
227
- * (information), never guessed. The standalone view has no workspace rules. → { problems, information } */
228
- function doctorTeamMigration(local) {
224
+ /** Local teams the workspace does not allow (team model 3: E_WORKSPACE_SCHEMA local-teams-closed), in
225
+ * doctor, OFFLINE like the rest of doctor: oats-local.yaml, and the workspace file this machine's parsed
226
+ * cache holds (cachedWorkspace: no git process, no network). Without that file, whether the local teams are
227
+ * allowed is said to be unchecked (information), never guessed. The standalone view has no workspace
228
+ * rules. → { problems, information } */
229
+ function doctorLocalTeams(local) {
229
230
  const standalone = typeof local.standalone === "string" && local.standalone !== "";
230
231
  const file = standalone ? null : cachedWorkspace(local.workspace)?.file ?? null;
231
232
  const model = teamModel(file, local);
232
- const unchecked = !standalone && file === null && model.migration.teamKeys.length > 0;
233
- return { problems: migrationProblems(model),
234
- information: unchecked ? ["team-model-3-migration: whether oats-local.yaml teams/defaultTeam need localTeams: true couldn't be checked: this deployment hasn't observed its workspace yet; run oats sync"] : [] };
233
+ const declared = ["teams", "defaultTeam"].some((k) => Object.hasOwn(local, k));
234
+ return { problems: model.closedKeys.length ? [localTeamsClosedProblem(model.closedKeys)] : [],
235
+ information: !standalone && file === null && declared ? ["local-teams-closed: whether oats-workspace.yaml allows oats-local.yaml teams/defaultTeam (localTeams: true) couldn't be checked: this deployment hasn't observed its workspace yet; run oats sync"] : [] };
235
236
  }
236
237
 
237
238
  /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
@@ -411,7 +412,9 @@ async function workspaceTarget(bail, { command, liveTeams = true }) {
411
412
  if (!existsSync(join(deployment, "oats-local.yaml"))) return bail("E_HOME_MISMATCH", `${homeFlag} is not at <deployment>/agents/<soul>/instances/<name>: ${deployment} has no oats-local.yaml`, { home: homeFlag, expected: join(deployment, "oats-local.yaml") });
412
413
  if (flag("dir") !== undefined) { let given = null; try { given = dirname(loadLocal(dirFlag()).path); } catch { given = dirFlag(); } if (realOrResolved(given) !== realOrResolved(deployment)) return bail("E_HOME_MISMATCH", `--dir ${dirFlag()} is not the deployment of ${homeFlag} (${deployment}); omit --dir for a home`); }
413
414
  if (rootFlag && realOrResolved(rootFlag) !== realOrResolved(join(deployment, "agents"))) return bail("E_HOME_MISMATCH", `--agents-root ${rootFlag} is not the agents root of ${homeFlag}`);
414
- return homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams });
415
+ // A typed refusal (the deployment's removed team keys, team model 3) answers as one, as for --soul.
416
+ try { return await homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams }); }
417
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
415
418
  }
416
419
  try { if (!isWorkspaceContext(dirFlag())) return bail("E_LOCAL_MISSING", `${command} reads a workspace deployment, and none is in reach of ${dirFlag()} (no oats-local.yaml walking up; \`oats onboard\` creates one) — or pass --home <abs> of a workspace instance`); }
417
420
  catch (e) { return bail(e?.code || "E_WORKSPACE_SCHEMA", e?.message || String(e), e?.details); }
@@ -597,13 +600,13 @@ function legacyLayoutProblems(root) {
597
600
  async function doctorWorkspaceJson(ctx, soulName, ws) {
598
601
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
599
602
  const agentsRoot = join(dirname(ws.local.path), "agents");
600
- const migration = doctorTeamMigration(ws.local.value);
601
- const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...migration.problems].filter(Boolean);
603
+ const localTeams = doctorLocalTeams(ws.local.value);
604
+ const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...localTeams.problems].filter(Boolean);
602
605
  return {
603
606
  schemaVersion: 1, workspaceApi: 2, context: ctx,
604
607
  workspace: { file: ws.local.path, ref: ws.local.workspace },
605
608
  workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
606
- information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...migration.information],
609
+ information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...localTeams.information],
607
610
  composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
608
611
  ...(problems.length ? { problems } : {}),
609
612
  };
@@ -638,10 +641,10 @@ async function doctor(dir) {
638
641
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg) => die(`${msg} [${code}]`));
639
642
  printDoctorWorkspace(ws);
640
643
  const agentsRoot = join(dirname(ws.local.path), "agents");
641
- const migration = doctorTeamMigration(ws.local.value);
644
+ const localTeams = doctorLocalTeams(ws.local.value);
642
645
  for (const p of [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot)].filter(Boolean)) console.log(`\n! ${p.code}: ${p.message}`);
643
- for (const p of migration.problems) console.log(`\n! ${p.code}: ${p.message} — ${p.fix}`);
644
- for (const line of migration.information) console.log(`\nINFO: ${line}`);
646
+ for (const p of localTeams.problems) console.log(`\n! ${p.code} (${p.condition}): ${p.message}`);
647
+ for (const line of localTeams.information) console.log(`\nINFO: ${line}`);
645
648
  if (soulName) {
646
649
  const information = operationalKnowledgeNote(composition, soulName);
647
650
  if (information) console.log(`\nINFO: ${information}`);
@@ -1083,11 +1086,8 @@ function maxAgeRefusal(command, head) {
1083
1086
  case "spawn": return head.includes("--preview") ? null : refuse("spawn");
1084
1087
  case "workspace": return word(1) === "status" ? null : refuse(["workspace", word(1)].filter(Boolean).join(" "));
1085
1088
  case "teams": return word(1) === undefined ? null : refuse(`teams ${word(1)}`);
1086
- case "soul": {
1087
- if (word(1) !== "teams") return refuse(["soul", word(1)].filter(Boolean).join(" "));
1088
- const edit = ["--add", "--remove", "--default", "--clear-default"].find((f) => head.includes(f));
1089
- return edit ? refuse(`soul teams ${edit}`) : null;
1090
- }
1089
+ // `soul teams` only reads (its edit flags were removed in 0.38.0, and refuse as such).
1090
+ case "soul": return word(1) === "teams" ? null : refuse(["soul", word(1)].filter(Boolean).join(" "));
1091
1091
  default: {
1092
1092
  const sub = ["package", "schedule", "session", "trigger", "automations", "launch-config", "server", "instance", "operation", "pane"].includes(command) ? word(1) : undefined;
1093
1093
  return refuse([command, sub].filter(Boolean).join(" "));
@@ -1175,8 +1175,8 @@ function soulLaunchFacts(entry, local, { launchConfigs, contextDir }) {
1175
1175
  /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1176
1176
  * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1177
1177
  * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE).
1178
- * Each soul row carries its teams HERE (team model v2: `teams`, `defaultTeam`), from the committed
1179
- * shared teams and `local` (oats-local.yaml); both null when its teams do not resolve (E_TEAM_*). */
1178
+ * Each soul row carries its teams HERE (team model 3: `teams`, `defaultTeam`), from the committed
1179
+ * workspace and the local teams it allows; both null when its teams do not resolve (isTeamRefusal). */
1180
1180
  function workspaceItems(discovery, lock, local, deploymentDir) {
1181
1181
  const souls = [];
1182
1182
  let launchConfigs = {};
@@ -1185,8 +1185,8 @@ function workspaceItems(discovery, lock, local, deploymentDir) {
1185
1185
  const capabilities = [];
1186
1186
  const model = teamModel(discovery.standalone === true ? null : discovery.workspace, local, { workspaceKey: discovery.key ?? null });
1187
1187
  const teamsHere = (entry) => {
1188
- try { const t = soulTeams(model, soulKeyOf(entry)); return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam }; }
1189
- catch (e) { if (String(e?.code).startsWith("E_TEAM_")) return { teams: null, defaultTeam: null }; throw e; }
1188
+ try { const t = soulTeams(model, teamKeyOf(entry)); return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam }; }
1189
+ catch (e) { if (isTeamRefusal(e)) return { teams: null, defaultTeam: null }; throw e; }
1190
1190
  };
1191
1191
  for (const m of discovery.members) {
1192
1192
  if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
@@ -1589,22 +1589,23 @@ async function workspaceStatusFacts(result, discovery, lock, ctx) {
1589
1589
  }
1590
1590
  }
1591
1591
 
1592
- /** The deployment's team facts for the team verbs: oats-local.yaml and the committed shared teams
1593
- * (the workspace host read now; none in a standalone view). */
1592
+ /** The deployment's team facts for the team verbs: oats-local.yaml, the committed workspace file and the
1593
+ * souls it offers (the workspace discovered now; none in a standalone view). */
1594
1594
  async function teamsContext(bail) {
1595
1595
  const ctx = workspaceContext(bail);
1596
- let workspace = null, workspaceKey = null;
1597
- if (!(typeof ctx.local.standalone === "string" && ctx.local.standalone)) {
1598
- try { const { observeWorkspace } = await import("../lib/workspace.mjs"); const obs = await observeWorkspace(ctx.local.workspace, { remoteOptions: ctx.remoteOptions }); workspace = obs.workspace; workspaceKey = obs.key; }
1599
- catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1600
- }
1601
- return { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace, workspaceKey, remoteOptions: ctx.remoteOptions };
1596
+ let discovery;
1597
+ try {
1598
+ const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1599
+ discovery = await discoverOrStandalone(ctx.local, { lock: readLockIfPresent(ctx.deploymentDir), deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions });
1600
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1601
+ const standalone = discovery.standalone === true;
1602
+ return { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace: standalone ? null : discovery.workspace, workspaceKey: standalone ? null : discovery.key ?? null,
1603
+ soulKeys: discoveredTeamKeys(discovery), remoteOptions: ctx.remoteOptions };
1602
1604
  }
1603
- const labelsFlag = (name) => { const v = valueFlag(name); return v === undefined ? [] : String(v).split(",").map((l) => l.trim()).filter(Boolean); };
1604
1605
  const positional = (i) => (args[i] !== undefined && !args[i].startsWith("--") ? args[i] : undefined);
1605
1606
 
1606
1607
  /** `oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label> | default <label>` —
1607
- * this deployment's teams (team model v2). Config only: never a provider call. */
1608
+ * this deployment's teams (team model 3). Config only: never a provider call. */
1608
1609
  async function teamsCmd() {
1609
1610
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1610
1611
  const usage = "usage: oats teams [--json] | oats teams add <label> --team <id> [--description <d>] | oats teams remove <label> | oats teams default <label> [--dir <deployment>] [--json]";
@@ -1623,25 +1624,29 @@ async function teamsCmd() {
1623
1624
  const doc = V.teamsDocument(result ? { ...ctx, local: result.local } : ctx);
1624
1625
  if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1625
1626
  if (result) console.log(result.changed ? `${sub === "add" ? `Declared team ${label}` : sub === "remove" ? `Removed team ${label}` : `The default team is now ${label}`} in ${shortPath(ctx.localPath)}` : "Nothing to change");
1626
- console.log(`default ${doc.defaultTeam ?? "(none)"}`);
1627
+ console.log(`default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1628
+ console.log(`local ${doc.localTeams === null ? "allowed (standalone)" : doc.localTeams ? "allowed (localTeams: true)" : "not allowed"}`);
1627
1629
  if (!doc.teams.length) console.log("teams (none: `oats aweb setup` creates them, or `oats teams add <label> --team <id>`)");
1628
1630
  else printTable(["team", "id", "from", ""], doc.teams.map((t) => [t.label, t.team ?? "(no id yet)", t.from, t.default ? "default" : ""]));
1629
- const souls = Object.entries(doc.souls.teams).map(([k, l]) => `${k}: ${l.join(",")}`);
1631
+ const souls = Object.entries(doc.souls).map(([k, e]) => `${k}: ${[e.default ? `default ${e.default}` : null, e.teams === "any" ? "any" : Array.isArray(e.teams) ? `[${e.teams.join(",")}]` : null].filter(Boolean).join(" ") || "default only"}`);
1630
1632
  if (souls.length) console.log(`souls ${souls.join(" · ")}`);
1631
- const defaults = Object.entries(doc.souls.default).map(([k, l]) => `${k}: ${l}`);
1632
- if (defaults.length) console.log(`defaults ${defaults.join(" · ")}`);
1633
1633
  for (const p of doc.problems) console.log(`${p.severity === "failure" ? "problem" : "warning"} ${p.code} ${p.message} — ${p.fix}`);
1634
1634
  }
1635
1635
 
1636
- /** `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <label> | --clear-default] [--json]` —
1637
- * which teams a soul belongs to here, and why (team model v2). Config only. */
1636
+ /** The `oats soul teams` edit flags team model 3 removed (0.38.0): a soul's teams are the workspace's `souls:`. */
1637
+ const SOUL_TEAMS_REMOVED_FLAGS = ["--add", "--remove", "--default", "--clear-default"];
1638
+ const SOUL_TEAMS_REPLACEMENT = "souls: in oats-workspace.yaml (a PR to the workspace file)";
1639
+
1640
+ /** `oats soul teams <soul>|'*' [--json]` — which teams a soul may join here, its default, and why (team
1641
+ * model 3: the workspace's `souls:`, and the local teams it allows). Config only, read only. */
1638
1642
  async function soulCmd() {
1639
1643
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1640
- const usage = "usage: oats soul teams <soul>|'*' [--add <label>[,…]] [--remove <label>[,…]] [--default <label> | --clear-default] [--dir <deployment>] [--json]";
1644
+ const usage = "usage: oats soul teams <soul>|'*' [--dir <deployment>] [--max-age <s>] [--json]";
1645
+ const removed = SOUL_TEAMS_REMOVED_FLAGS.find((f) => args.some((a) => a === f || a.startsWith(`${f}=`)));
1646
+ if (removed) bail("E_BAD_ARGS", `oats soul teams ${removed} was removed in 0.38.0 (team model 3): which teams a soul may join, and its default, are ${SOUL_TEAMS_REPLACEMENT}`, { flag: removed, replacement: SOUL_TEAMS_REPLACEMENT });
1641
1647
  if (positional(1) !== "teams") bail("E_USAGE", usage);
1642
1648
  const name = positional(2);
1643
- if (name === undefined) bail("E_BAD_ARGS", `oats soul teams needs a soul (or '*' for every soul) — ${usage}`);
1644
- const edit = { add: labelsFlag("add"), remove: labelsFlag("remove"), setDefault: valueFlag("default") ?? null, clearDefault: args.includes("--clear-default") };
1649
+ if (name === undefined) bail("E_BAD_ARGS", `oats soul teams needs a soul (or '*') — ${usage}`);
1645
1650
  const ctx = workspaceContext(bail);
1646
1651
  let teamsCtx, soul = "*", key = "*";
1647
1652
  if (name === "*") teamsCtx = await teamsContext(bail);
@@ -1655,19 +1660,16 @@ async function soulCmd() {
1655
1660
  discovery = await discoverOrStandalone(ctx.local, { lock, deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions });
1656
1661
  entry = findSoulEntry(discovery, name);
1657
1662
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1658
- soul = entry.name; key = soulKeyOf(entry);
1663
+ soul = entry.name; key = teamKeyOf(entry);
1659
1664
  teamsCtx = { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace: discovery.standalone === true ? null : discovery.workspace, workspaceKey: discovery.key ?? null };
1660
1665
  }
1661
1666
  const V = await import("../lib/teams-verbs.mjs");
1662
- const mutating = edit.add.length || edit.remove.length || edit.setDefault !== null || edit.clearDefault;
1663
- let result = null, doc;
1664
- try {
1665
- if (mutating) result = V.soulTeamsEdit(teamsCtx, key, edit);
1666
- doc = V.soulTeamsDocument(result ? { ...teamsCtx, local: result.local } : teamsCtx, { soul, key });
1667
- } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1668
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1669
- if (result) console.log(result.changed ? `Updated the teams of ${key === "*" ? "every soul" : key} in ${shortPath(teamsCtx.localPath)}` : "Nothing to change");
1670
- console.log(`${key === "*" ? "every soul" : key} on this computer: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1667
+ let doc;
1668
+ try { doc = V.soulTeamsDocument(teamsCtx, { soul, key }); }
1669
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1670
+ if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
1671
+ console.log(`${key === "*" ? "unlisted souls (\"*\")" : key} here: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1672
+ console.log(`souls: ${doc.match === null ? "no entry matches" : `teams from ${JSON.stringify(doc.match)}`}${doc.defaultMatch !== null ? `, default from ${JSON.stringify(doc.defaultMatch)}` : ""}`);
1671
1673
  if (doc.teams.length) printTable(["team", "id", "from", "why"], doc.teams.map((t) => [t.default ? `${t.label} (default)` : t.label, t.team ?? "(no id yet)", t.from, t.via.join(",")]));
1672
1674
  }
1673
1675
 
@@ -1904,8 +1906,8 @@ async function statusDrift(data) {
1904
1906
  * machine's parsed cache knows: `ref` itself when the cache holds its workspace file, or the host its
1905
1907
  * cached oats-membership.yaml names when `ref` is a member ("workspace"). A member whose host is not
1906
1908
  * known keys as itself ("member"); a ref nothing is known of is taken as the host, as the schema
1907
- * defines `workspace:` ("workspace"). The team model resolves as teamModel resolves it (the default
1908
- * label is local; a committed team wins a collision) over the shared teams of, in order, the workspace
1909
+ * defines `workspace:` ("workspace"). The team model resolves as teamModel resolves it (the default is
1910
+ * the local one the workspace allows, else the workspace's; a committed team wins a collision) over the shared teams of, in order, the workspace
1909
1911
  * file this run observed (`teamsFrom: "observed"`), the cached file ("cache": no git process), or none
1910
1912
  * ("local"). `standalone` is the CONFIGURED standalone view only (oats-local.yaml `standalone:`): it
1911
1913
  * reads no workspace file, so its local teams are the whole team model. A run that fell back to the
@@ -1928,10 +1930,12 @@ function workspaceIdentity(local, discovery) {
1928
1930
  else if (cached?.file) [shared, teamsFrom] = [cached.file, "cache"];
1929
1931
  }
1930
1932
  const model = teamModel(shared, local);
1933
+ // A soul with no souls: default of its own lives here (team model 3: the local default the workspace allows, else the workspace's).
1934
+ const deploymentDefault = model.localDefault ?? model.workspaceDefault;
1931
1935
  const labels = [...model.labels.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1932
1936
  return {
1933
1937
  key, ref, keyFrom, standalone,
1934
- defaultTeam: model.defaultTeam === null ? null : { label: model.defaultTeam, team: model.labels.get(model.defaultTeam)?.team ?? null },
1938
+ defaultTeam: deploymentDefault === null ? null : { label: deploymentDefault, team: model.labels.get(deploymentDefault)?.team ?? null },
1935
1939
  teams: Object.fromEntries(labels.map((l) => [l, model.labels.get(l).team])),
1936
1940
  teamsFrom,
1937
1941
  };
@@ -2010,8 +2014,8 @@ async function status() {
2010
2014
  const verbose = args.includes("--verbose");
2011
2015
  const problems = legacyLayoutProblems(root);
2012
2016
  if (args.includes("--json")) {
2013
- // The soul key (feature launch-preference): what souls.teams / souls.default / souls.launch and
2014
- // `oats soul teams <key>` use — from the instances' records, so it needs no remote.
2017
+ // The soul key (feature launch-preference): what souls.launch and `oats soul teams <key>` use —
2018
+ // from the instances' records, so it needs no remote.
2015
2019
  for (const a of data) a.key = agentSoulKey(a);
2016
2020
  if (ws) for (const a of data) {
2017
2021
  const stamp = ws.souls.get(a.name);
@@ -3002,7 +3006,9 @@ async function capabilityCommand() {
3002
3006
  if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
3003
3007
  if (homeMeta && m.capability === homeMeta.messaging) {
3004
3008
  const { liveTeams } = await import("../lib/instance-resolution.mjs");
3005
- const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
3009
+ let live;
3010
+ try { live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() }); }
3011
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
3006
3012
  teamCtx = homeTeamCtx(live);
3007
3013
  }
3008
3014
  return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, () => ({ dir: soulDir, cleanup: () => {} }));
@@ -3136,7 +3142,7 @@ function versionCmd() {
3136
3142
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3137
3143
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3138
3144
  // never listed.
3139
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3145
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3140
3146
  return;
3141
3147
  }
3142
3148
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3810,10 +3816,10 @@ Usage:
3810
3816
  teams on this deployment
3811
3817
  oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label>
3812
3818
  | default <label> [--dir <d>] this deployment's teams (shared + local), the
3813
- [--max-age <s>] (the read form only) default; add/remove/default edit oats-local.yaml
3814
- oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]
3815
- [--dir <d>] [--json] which teams a soul (or every soul) belongs to here
3816
- [--max-age <s>] (without an edit)
3819
+ [--max-age <s>] (the read form only) default, the workspace's souls:; add/remove/default
3820
+ edit oats-local.yaml (only with localTeams: true)
3821
+ oats soul teams <soul>|'*' [--dir <d>] which teams a soul may join here, its default, and
3822
+ [--max-age <s>] [--json] why (the workspace's souls: and local teams)
3817
3823
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
3818
3824
  read-only Git observation of the instance's work
3819
3825
  tree: branch, status (renames kept), ahead/behind
@@ -193,23 +193,27 @@ per-deployment activation or exclusion maps.
193
193
 
194
194
  ### Teams in the provider environment
195
195
 
196
- A soul's teams here (the committed shared teams, and the deployment's `oats-local.yaml` `teams`, `defaultTeam`, `souls.teams`,
197
- `souls.default` — see [workspaces.md](workspaces.md#teams)) travel **beside** a
196
+ A soul's teams here (the workspace's shared teams, `defaultTeam` and `souls:`, and
197
+ the deployment's `oats-local.yaml` `teams` and `defaultTeam` where the workspace
198
+ allows them — see [workspaces.md](workspaces.md#teams)) travel **beside** a
198
199
  provider's settings, never inside them, in the environment of every hook, home
199
200
  command and provider check:
200
201
 
201
202
  - `OATS_DEFAULT_TEAM` — the soul's default label; `OATS_DEFAULT_TEAM_ID` — its
202
- provider id; `OATS_DEFAULT_TEAM_FROM` — `deployment` (`defaultTeam`) or `soul`
203
- (`souls.default`).
203
+ provider id; `OATS_DEFAULT_TEAM_FROM` — `soul` (the soul's `souls:` default in
204
+ the workspace file), `deployment` (the local `defaultTeam`) or `workspace` (the
205
+ workspace's `defaultTeam`).
204
206
  - No default configured: none of the three is set. An **unmapped** default (a
205
207
  shared team declared without an id): `OATS_DEFAULT_TEAM` and
206
208
  `OATS_DEFAULT_TEAM_FROM` are set and `OATS_DEFAULT_TEAM_ID` is not. What a
207
209
  provider does then is its own contract; a messaging provider typically
208
210
  refuses the spawn, naming the unmapped label, or saying no team is
209
211
  configured.
210
- - `OATS_TEAMS` — JSON `[{label, team, default, from: "shared"|"local"}]`: every
211
- mapped team the soul may be in here, the default included (`default: true`),
212
- default first, then by label. Eligible to join = the rows with `default: false`.
212
+ - `OATS_TEAMS` — JSON `[{label, team, default, from: "shared"|"local", via}]`:
213
+ every mapped team the soul may be in here, the default included
214
+ (`default: true`), default first, then by label; `via` says why
215
+ (`default`, `workspace`, `local`). Eligible to join = the rows with
216
+ `default: false`, and no other team: a provider refuses a join outside them.
213
217
  Unset when a home's teams are unknown (none recorded, and unreadable now).
214
218
  - `OATS_TEAMS_SOURCE` — `live` (the workspace and `oats-local.yaml` read now, or
215
219
  a fresh resolution) or `recorded` (the spawn-time record). **A provider leaves
@@ -341,7 +345,9 @@ Hooks receive:
341
345
  A spawn hook also gets `OATS_TASK`, `OATS_REPO`, `OATS_BRANCH`, `OATS_WORK`,
342
346
  `OATS_HARNESS`, `OATS_KIND` and, for a spawn a trigger started,
343
347
  `OATS_TRIGGER_EVENT_FILE`. A launch hook also gets `OATS_HARNESS` and
344
- `OATS_PREVIOUS_HARNESS`.
348
+ `OATS_PREVIOUS_HARNESS`, and `OATS_LAUNCH_PREVIEW=1` when it runs for a
349
+ preview (only a preview-aware hook does, below); on a real run
350
+ `OATS_LAUNCH_PREVIEW` is not set.
345
351
 
346
352
  `OATS_SETTINGS_ORIGINS` says where each leaf of
347
353
  `OATS_SETTINGS` came from: a JSON object from a JSON pointer to `{ kind, at }`,
@@ -350,7 +356,8 @@ A spawn hook also gets `OATS_TASK`, `OATS_REPO`, `OATS_BRANCH`, `OATS_WORK`,
350
356
  `{"/harvest":{"kind":"soul","at":"soul.yaml#/knowledge"}}`. A provider tells a
351
357
  soul-set value from a host-set one there, and never reads `soul.yaml` for it;
352
358
  a home with none recorded gives `{}`. A final JSON line may return `meta`,
353
- `brief`, `warning`, or harness-specific `launch` arguments. A **spawn or
359
+ `brief`, `warning`, or harness-specific `launch` arguments; a preview-aware
360
+ launch hook's preview answer may add `volatileEnv` (below). A **spawn or
354
361
  launch hook** may also return an `env` object for the launched process;
355
362
  returning `env` from a retire hook is an explicit contract error.
356
363
 
@@ -364,6 +371,54 @@ start (a renewed session grant, for example) leaves the CURRENT one on record. A
364
371
  launch hook that answers without `meta` keeps its previous entry; a start whose
365
372
  preparation fails changes nothing.
366
373
 
374
+ A launch hook may do idempotent provider registration on a real start (an
375
+ aweb home registering with the host wake broker, for example). How the
376
+ kernel runs it depends on whether its capability declares **preview
377
+ awareness**: `"launchPreview": true` at the top level of its manifest. The
378
+ kernel reads the declaration from the home's own module copy, so a home keeps
379
+ the behaviour of the module it was spawned with.
380
+
381
+ **A preview-aware hook** must change nothing under `OATS_LAUNCH_PREVIEW=1`,
382
+ and must return the same contribution (`launch` arguments and `env`) as for
383
+ a real start. It runs twice per start:
384
+
385
+ 1. **As a preview, under `OATS_LAUNCH_PREVIEW=1`.** The start's preflight uses
386
+ this contribution: trust, environment ownership, the harness-package
387
+ probe and the rendered command. `oats launch-config preview` (which
388
+ Desktop's start dialog uses) runs only this pass.
389
+ 2. **For real, without the flag.** This pass runs only once preflight has
390
+ passed, including the check that the home is not already running
391
+ (`E_SESSION_RUNNING`), and before a restart stops the running harness.
392
+
393
+ The real run's `meta` and warnings are what the start records. If its
394
+ contribution differs from its preview contribution, the start is refused
395
+ with `E_LAUNCH_PREPARATION` and nothing is stopped or started.
396
+
397
+ Some values only a real run can know, such as a credential minted at start.
398
+ A preview answer may list those names in `volatileEnv` (for example
399
+ `"volatileEnv": ["AWEB_IDENTITY_HOME"]`, beside `env`). For those names:
400
+
401
+ - The start takes the values from the real run, records them, and renders
402
+ the launch command again with them.
403
+ - The comparison leaves those values out. Everything else must still be
404
+ identical.
405
+
406
+ Each name must be one the same hook returned in `env`. Preflight sees only
407
+ the preview's value, so a volatile name must not affect how the harness
408
+ resolves its packages. The kernel refuses (`E_LAUNCH_PREPARATION`) a volatile
409
+ `CLAUDE_CONFIG_DIR`, `CODEX_HOME` or `PI_CODING_AGENT_DIR`. It reads
410
+ `volatileEnv` only from a preview answer and never records it.
411
+
412
+ **A hook that does not declare preview awareness** runs once per start, for
413
+ real, during preflight, before the checks that use its contribution. A
414
+ refused start may therefore already have run it. `oats launch-config preview`
415
+ never runs it. The preview shows that capability's recorded contribution, and
416
+ its `capabilities` check says the hook was not run.
417
+
418
+ `launchPreview` is a top-level key so that a kernel older than 0.37 ignores
419
+ it and runs the hook once, as it always did. A key inside the hook's
420
+ declaration would make such a kernel refuse the whole package.
421
+
367
422
  Hook environment values are strings, at most 8192 UTF-8 bytes, with no NUL or
368
423
  newlines. Names use the portable environment grammar and must belong to an
369
424
  unambiguous vendor namespace. Only a dotted capability ID participates: its
@@ -61,6 +61,10 @@
61
61
  "type": "boolean",
62
62
  "description": "Workspace discovery: true makes this member capability repo-owned — listed (private: true) but usable only by souls of its own repository (E_CAPABILITY_PRIVATE elsewhere)."
63
63
  },
64
+ "launchPreview": {
65
+ "type": "boolean",
66
+ "description": "true declares the launch hook preview-aware: it changes nothing under OATS_LAUNCH_PREVIEW=1 and returns the same contribution (apart from its preview answer's volatileEnv values), so a start runs it as a preview during preflight and for real after it, and a launch preview runs it. Without it the hook runs once per start, for real, during preflight, and never for a launch preview. Top-level so that older kernels ignore it."
67
+ },
64
68
  "layer": {
65
69
  "enum": [
66
70
  "knowledge",
@@ -21,15 +21,10 @@ settings: # host-owned values per capability
21
21
  oats.okf:
22
22
  bindings-file: /Users/ana/.oats/okf-bindings.json
23
23
 
24
- teams: # LOCAL teams: only this deployment uses them
24
+ teams: # LOCAL teams (only where the workspace says localTeams: true)
25
25
  ana-research: { team: "ana-research:acme.aweb.ai", description: Ana's research }
26
- defaultTeam: ana-research # the team every instance lives in
26
+ defaultTeam: ana-research # this deployment's default team (same condition)
27
27
  souls:
28
- teams:
29
- "*": [ana-research] # every soul is in these teams here
30
- data-analyst: [platform] # and this one also joins a shared team
31
- default:
32
- data-analyst: platform # per-soul override of defaultTeam
33
28
  disabled: [legacy-bot] # souls not run on this machine
34
29
 
35
30
  host:
@@ -66,10 +61,8 @@ refused (`E_WORKSPACE_SCHEMA`).
66
61
  | `standalone` | A repo ref to realize on its own: its souls and `from: here` capabilities plus `oats.core`, with no workspace lookup. For a repository whose workspace this machine cannot read ([workspaces.md](workspaces.md#the-standalone-case)). |
67
62
  | `clones` | `<repo key>: <absolute path>` for a member clone that is not at `<deployment>/<member name>/`. Only a soul whose work target needs a clone (`work: worktree \| checkout`) uses it. Lookup order: `spawn --repo`, then this map, then `<deployment>/<member name>` (a member named `agents` → `<deployment>/agents-repo`). None → `E_CLONE_MISSING`; a directory whose `origin` is another repository → `E_CLONE_MISMATCH`. |
68
63
  | `settings.<cap>.<key>` | Host-owned values a capability's manifest asks for: absolute paths, state roots, delivery modes. The workspace file refuses absolute paths; they go here. Merged into the capability's provider payload after the soul's own and before any `--provider` flag ([three homes](workspaces.md#provider-payloads-have-three-homes)). |
69
- | `teams.<label>` | A **local** team: `{ team: <provider team id>, description? }`. Shared teams are committed in `oats-workspace.yaml`; a label in both is refused. Written by `oats teams add <label> --team <id>` and `oats teams remove <label>`. |
70
- | `defaultTeam` | The team every instance of this deployment lives in: a label of a local or shared team. The first `oats teams add` sets it; `oats teams default <label>` changes it. |
71
- | `souls.teams` | Which teams each soul joins here: `"*"` applies to every soul; a soul's own entry (its name, or `<package>/<soul>`) adds to it. Every soul is also in its default team. Written by `oats soul teams <soul>\|'*' --add … --remove …`. |
72
- | `souls.default` | A per-soul override of `defaultTeam`; it must be one of that soul's teams here (`E_TEAM_NOT_ELIGIBLE`). Written by `oats soul teams <soul> --default <label>`. |
64
+ | `teams.<label>` | A **local** team: `{ team: <provider team id>, description? }`, a team only this deployment uses; every soul may join it. Allowed only where `oats-workspace.yaml` says `localTeams: true` (or in the standalone view); otherwise refused (`E_WORKSPACE_SCHEMA`, reason `local-teams-closed`). Shared teams are committed in `oats-workspace.yaml`; a label in both is `team-label-collision` (the shared one wins). Written by `oats teams add <label> --team <id>` and `oats teams remove <label>`. |
65
+ | `defaultTeam` | This deployment's default team: a label of a local or shared team, under the same condition as `teams`. A soul's own default in the workspace's `souls:` wins over it; it wins over the workspace's `defaultTeam`. The first `oats teams add` sets it; `oats teams default <label>` changes it. |
73
66
  | `souls.disabled` | Souls not run on this machine; a spawn is refused with `E_SOUL_DISABLED`. A bare name disables every soul of that name; `<package>/<soul>` or `<member>/<soul>` disables one. |
74
67
  | `session.tmuxSession` | The tmux session new tmux instances open their windows in (0.31). Absent: `OATS_TMUX_SESSION`, else `PI_AGENTS_TMUX_SESSION` (the pre-0.31 variable), else `oats-agents`. `session: { tmuxSession: pi-agents }` keeps the pre-0.31 layout. `oats inspect --json` reports it as `session`. |
75
68
  | `host.name` | This machine's name. A workspace trigger or schedule runs only on the host named by its `runsOn` ([schedules.md](schedules.md)). |
@@ -78,14 +71,11 @@ refused (`E_WORKSPACE_SCHEMA`).
78
71
  | `launch-configs.<name>` | A named way to start a harness on this host, chosen at spawn or session start, never by the soul. `default: true` makes it this host's baseline for its harness (0.32). See [Launch configurations](#launch-configurations). |
79
72
  | `souls.launch` | This machine's launch preference per soul (0.30): `"*"` for every soul, a soul's own entry (its name, or `<package>/<soul>`) over it. A value is a `launch-configs` name or an inline `{ harness, model? }`. It overrides the soul's own `launch:`; explicit spawn flags win over both. See [Launch preferences](#launch-preferences). |
80
73
 
81
- How teams are resolved, and what a messaging provider does with them, is in
82
- [workspaces.md](workspaces.md#teams).
83
-
84
- OATS 0.37.0 (team model 3) removes `souls.teams` and `souls.default` (they move
85
- to `souls:` in `oats-workspace.yaml`) and allows `teams` and `defaultTeam` here
86
- only when the workspace file says `localTeams: true`. 0.36.x still applies all
87
- four keys and warns about them (`team-model-3-migration`): see
88
- [Preparing for team model 3](workspaces.md#preparing-for-team-model-3-036x).
74
+ Which teams a soul may join, and its default, are committed in the workspace's
75
+ `souls:`, never here: `souls.teams` and `souls.default` were removed in 0.38.0
76
+ (`E_WORKSPACE_SCHEMA`, reason `removed-key`; the refusal prints the `souls:` to
77
+ commit instead). How teams are resolved, and what a messaging provider does
78
+ with them, is in [workspaces.md](workspaces.md#teams).
89
79
 
90
80
  ## Launch configurations
91
81
 
@@ -242,7 +232,12 @@ recipe is resolved again against the home's recorded context and every check
242
232
  runs first. With `--reselect-launch`, the launch preferences decide again
243
233
  (the home's recorded soul and this deployment's `souls.launch`). A capability that contributed harness-specific arguments must
244
234
  declare a `launch` hook to follow a harness change; otherwise the start is
245
- refused (`E_LAUNCH_PREPARATION`). A launch hook's warnings do not stop
235
+ refused (`E_LAUNCH_PREPARATION`). The checks use the preview run
236
+ (`OATS_LAUNCH_PREVIEW=1`) of the launch hooks whose capabilities declare
237
+ `launchPreview`. Those hooks run for real only after every check has passed,
238
+ so a start refused by preflight has run none of them for real. Other launch
239
+ hooks run once, for real, during the checks (see
240
+ [capabilities.md](capabilities.md)). A launch hook's warnings do not stop
246
241
  the start: `session start|restart` print them (and answer them as
247
242
  `warnings` under `--json`), as spawn does, and each is kept as a
248
243
  `launch-warning` instance event (`oats instance events`).
@@ -327,7 +322,7 @@ is safe to delete.
327
322
  oats workspace status # members, locked packages, external souls
328
323
  oats sync # confirm, resolve, lock, report the diff
329
324
  oats teams # shared and local teams, and the default
330
- oats soul teams <soul> # the teams one soul joins here
325
+ oats soul teams <soul> # the teams one soul may join here, and why
331
326
  oats spawn <soul> --preview # the exact modules, teams and provider payloads
332
327
  oats doctor # this deployment's files and the lock
333
328
  ```
@@ -1,6 +1,6 @@
1
1
  # Team model v2: shared and local teams, the default team, and membership per deployment
2
2
 
3
- **Status:** decided and implemented (kernel 0.30.0, feature `team-model-2`). This record decides where teams, the default team and team membership live, how the kernel resolves them, and what the kernel hands a messaging provider. The reference pages ([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment), [desktop-cli-api.md](../desktop-cli-api.md#team-model-v2-feature-team-model-2-oats-0300-replaces-feature-teams)) win on operator-visible behaviour.
3
+ **Status:** superseded in part by [team model 3](2026-10-02-team-model-3.md) (kernel 0.38.0): which teams a soul may join and its default team are committed in the workspace's `souls:` and `defaultTeam`, and local teams need `localTeams: true`; `souls.teams`, `souls.default` and the `oats soul teams` edits below are removed. The rest stands. Decided and implemented (kernel 0.30.0, feature `team-model-2`). This record decides where teams, the default team and team membership live, how the kernel resolves them, and what the kernel hands a messaging provider. The reference pages ([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment), [desktop-cli-api.md](../desktop-cli-api.md#team-model-v2-feature-team-model-2-oats-0300-replaces-feature-teams)) win on operator-visible behaviour.
4
4
 
5
5
  ## Why
6
6