@awebai/oats 0.37.0 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +71 -65
- package/docs/capabilities.md +11 -7
- package/docs/configuration.md +10 -20
- package/docs/design/2026-09-27-team-model-v2.md +1 -1
- package/docs/design/2026-10-02-team-model-3.md +146 -0
- package/docs/design/README.md +5 -1
- package/docs/desktop-cli-api.md +159 -121
- package/docs/desktop.md +25 -5
- package/docs/first-team.md +14 -7
- package/docs/integrations.md +5 -2
- package/docs/oats-local.schema.json +2 -14
- package/docs/oats-workspace.schema.json +4 -4
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +10 -9
- package/docs/release-notes/v0.38.0.md +128 -0
- package/docs/release-notes/v0.38.1.md +50 -0
- package/docs/souls-and-instances.md +4 -3
- package/docs/workspaces.md +122 -77
- package/lib/instance-inspect.mjs +18 -15
- package/lib/instance-resolution.mjs +9 -6
- package/lib/resolve.mjs +5 -4
- package/lib/session-viewer.mjs +1 -0
- package/lib/teams-verbs.mjs +44 -70
- package/lib/teams.mjs +151 -112
- package/lib/workspace.mjs +15 -5
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +20 -9
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 {
|
|
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
|
-
/**
|
|
225
|
-
* local
|
|
226
|
-
* network). Without that file, whether local teams
|
|
227
|
-
* (information), never guessed. The standalone view has no workspace
|
|
228
|
-
|
|
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
|
|
233
|
-
return { problems:
|
|
234
|
-
information:
|
|
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
|
-
|
|
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
|
|
601
|
-
const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...
|
|
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)] : []), ...
|
|
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
|
|
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
|
|
644
|
-
for (const line of
|
|
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
|
-
|
|
1087
|
-
|
|
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
|
|
1179
|
-
*
|
|
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,
|
|
1189
|
-
catch (e) { if (
|
|
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
|
|
1593
|
-
* (the workspace
|
|
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
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
}
|
|
1601
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1637
|
-
|
|
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>|'*'
|
|
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 '*'
|
|
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 =
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
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
|
-
*
|
|
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:
|
|
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.
|
|
2014
|
-
//
|
|
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
|
-
|
|
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-
|
|
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
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
[--max-age <s>] (
|
|
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
|
package/docs/capabilities.md
CHANGED
|
@@ -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
|
|
197
|
-
`
|
|
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` — `
|
|
203
|
-
(`
|
|
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"}]`:
|
|
211
|
-
mapped team the soul may be in here, the default included
|
|
212
|
-
default first, then by label
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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 #
|
|
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? }
|
|
70
|
-
| `defaultTeam` |
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
only when the workspace file says `localTeams: true`. 0.36.x and 0.37.x still apply all
|
|
87
|
-
four keys and warn 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
|
|
|
@@ -332,7 +322,7 @@ is safe to delete.
|
|
|
332
322
|
oats workspace status # members, locked packages, external souls
|
|
333
323
|
oats sync # confirm, resolve, lock, report the diff
|
|
334
324
|
oats teams # shared and local teams, and the default
|
|
335
|
-
oats soul teams <soul> # the teams one soul
|
|
325
|
+
oats soul teams <soul> # the teams one soul may join here, and why
|
|
336
326
|
oats spawn <soul> --preview # the exact modules, teams and provider payloads
|
|
337
327
|
oats doctor # this deployment's files and the lock
|
|
338
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:**
|
|
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
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Team model 3: the teams a soul may join, and its default, are committed in the workspace
|
|
2
|
+
|
|
3
|
+
**Status:** decided and implemented (kernel 0.38.0, feature `team-model-3`; prepared in 0.36.1).
|
|
4
|
+
Specs: awebai/oats#484 and #485, agreed by both maintainers on 2026-10-02. This record supersedes the
|
|
5
|
+
membership and default-team parts of [team model v2](2026-09-27-team-model-v2.md); v2's shared teams,
|
|
6
|
+
unmapped ids, live teams and provider environment stand. The reference pages
|
|
7
|
+
([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment),
|
|
8
|
+
[desktop-cli-api.md](../desktop-cli-api.md#team-model-3-feature-team-model-3-oats-0370-replaces-feature-team-model-2))
|
|
9
|
+
win on operator-visible behaviour.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
**The local bridge.** An instance that sits in two teams is a bridge between them: one process reads
|
|
14
|
+
both inboxes, holds both contexts in one session, and can relay anything from one to the other. Under
|
|
15
|
+
team model v2, one person's `oats-local.yaml` could put an organisation's instance into a team the
|
|
16
|
+
organisation never sees: a local extra team in `souls.teams`, or a local default team while its souls
|
|
17
|
+
joined the organisation's shared teams. Either way it never showed in the organisation's git, and it
|
|
18
|
+
differed per person and per machine. The organisation owns its teams' trust boundary, so the teams its
|
|
19
|
+
instances can be in are its decision, committed in its workspace, and closed by default.
|
|
20
|
+
|
|
21
|
+
**Scale.** With many souls and teams, per-deployment lists were repeated by every person on every
|
|
22
|
+
machine.
|
|
23
|
+
|
|
24
|
+
**Principle**, consistent with v2's "shared facts committed, personal choices local": everything
|
|
25
|
+
workspace-specific lives in the workspace file; souls stay team-free in their repositories, open source
|
|
26
|
+
included; an organisation says how souls behave in its teams in its own `oats-workspace.yaml`, and
|
|
27
|
+
someone running the souls standalone sees none of it.
|
|
28
|
+
|
|
29
|
+
## What it protects against, and what it does not
|
|
30
|
+
|
|
31
|
+
The workspace's eligibility rules (`souls:`, `localTeams` closed by default) **prevent accidental joins**
|
|
32
|
+
in a cooperative installation, and make the organisation's intended team set visible and reviewable in
|
|
33
|
+
its git. That is all they claim.
|
|
34
|
+
|
|
35
|
+
They are not anti-bridging enforcement, and neither is the messaging layer. Distinct keys do not prove
|
|
36
|
+
distinct processes: an operator controlling two identities can read under one and relay under the
|
|
37
|
+
other, or copy content outside the messaging layer. aweb admission controls who holds credentials for
|
|
38
|
+
a team (the team controller key signs memberships; hosted invites are mediated by the service), not
|
|
39
|
+
what a process does with what it reads. A deployment's authority comes from the credentials it holds,
|
|
40
|
+
not from its path or its name. Today's team certificates carry no soul, workspace or home-team claim,
|
|
41
|
+
and any such metadata would be self-asserted, so it would not be prevention. Enforcement on the
|
|
42
|
+
messaging side (admission per root, claims in certificates, a namespace policy, bridge visibility for a
|
|
43
|
+
team's owner) is a separate question for the aweb protocol owners.
|
|
44
|
+
|
|
45
|
+
## The model
|
|
46
|
+
|
|
47
|
+
### `oats-workspace.yaml`
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
teams: # shared teams, as in v2
|
|
51
|
+
engineering: { team: "engineering:acme.aweb.ai" }
|
|
52
|
+
security: { team: "security:acme.aweb.ai" }
|
|
53
|
+
docs: { team: "docs:acme.aweb.ai" }
|
|
54
|
+
defaultTeam: engineering # the workspace's fallback default team (a shared label)
|
|
55
|
+
localTeams: false # may deployments declare their own teams? (absent: false)
|
|
56
|
+
souls: # per pattern: the default team and the other teams a soul may join
|
|
57
|
+
"*": { teams: [] }
|
|
58
|
+
security-souls/*: { default: security, teams: [engineering] }
|
|
59
|
+
security-souls/incident-responder: { default: security, teams: [engineering, docs] }
|
|
60
|
+
oats.engineering/*: { teams: any }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Keys are patterns:** `<member>/<soul>` or `<package>/<soul>`, `<member>/*` or `<package>/*`, and
|
|
64
|
+
`"*"`, with the names `souls.disabled` already uses. A soul's key is its qualified name. Bare names are
|
|
65
|
+
refused: a bare name is ambiguous across members, and the qualified form is what the workspace sees.
|
|
66
|
+
- **The most specific key wins outright for teams,** with no merging across levels; the default comes
|
|
67
|
+
from the most specific key that sets one. `a/*: {default: security}` and `a/x: {teams: [docs]}` give
|
|
68
|
+
`a/x` the default `security` and the teams `[docs]` only. Merging was rejected: an entry that adds to
|
|
69
|
+
a broader one hides what a soul can join behind two places, and the point of the model is that a
|
|
70
|
+
reviewer reads one entry.
|
|
71
|
+
- **`any`** is every shared team of the file.
|
|
72
|
+
- **A soul no key matches** (and no `"*"`) has its default only, member and package souls alike; a
|
|
73
|
+
workspace opens a package explicitly.
|
|
74
|
+
- **Every label is a shared team of the same file.** An unknown label, or a default that isn't one, is
|
|
75
|
+
`E_WORKSPACE_SCHEMA` when the file is read, never at a spawn on someone else's machine. A key that is
|
|
76
|
+
neither a pattern nor a discovered soul's qualified name is the warning `team-soul-unknown` (a typo
|
|
77
|
+
guard; not an error, because the file is validated without discovery and packages come and go).
|
|
78
|
+
|
|
79
|
+
### The default team, in order
|
|
80
|
+
|
|
81
|
+
1. the soul's `default` from `souls:` (`from: "soul"`);
|
|
82
|
+
2. else the deployment's `oats-local.yaml` `defaultTeam`, only when `localTeams: true`
|
|
83
|
+
(`from: "deployment"`);
|
|
84
|
+
3. else the workspace's `defaultTeam` (`from: "workspace"`);
|
|
85
|
+
4. else none (`E_TEAM_UNCONFIGURED` when a messaging layer is active).
|
|
86
|
+
|
|
87
|
+
The soul's own default comes first because it is the organisation's most specific statement; a
|
|
88
|
+
personal default, where allowed, comes before the workspace's fallback so a person can keep their own
|
|
89
|
+
default team (awebai/oats sets `localTeams: true` for this).
|
|
90
|
+
|
|
91
|
+
### The teams a soul may join
|
|
92
|
+
|
|
93
|
+
Its default, plus the `teams` of its most specific matching key, plus, with `localTeams: true`, every
|
|
94
|
+
local team the deployment declares (there is no per-soul local list: a deployment that is trusted with
|
|
95
|
+
local teams is trusted with them for every soul). Each row says why: `via` ⊂ `default`, `workspace`,
|
|
96
|
+
`local`. Unchanged from v2: an instance joins only its default at spawn; the others are offered and
|
|
97
|
+
joined on request, and the messaging provider decides admission from the eligible rows it receives in
|
|
98
|
+
`OATS_TEAMS`.
|
|
99
|
+
|
|
100
|
+
### `oats-local.yaml`
|
|
101
|
+
|
|
102
|
+
- `souls.teams` and `souls.default` are removed keys. The refusal names each key found and prints the
|
|
103
|
+
`souls:` that gives the same result (a v2 soul's teams were its default ∪ `"*"` ∪ its own list, so each
|
|
104
|
+
named soul's entry lists `"*"`'s teams too; a bare soul name is written `<member>/<soul>` for the
|
|
105
|
+
operator to qualify), so a deployment's migration is a copy, a qualification and a PR.
|
|
106
|
+
- `teams` and `defaultTeam` are allowed only when the workspace says `localTeams: true`. Otherwise every
|
|
107
|
+
spawn, preview and inspect refuses with `E_WORKSPACE_SCHEMA` (reason `local-teams-closed`), and `oats
|
|
108
|
+
teams`, readiness and doctor report it as a failure. The message names both fixes: allow local teams,
|
|
109
|
+
or commit them in the workspace and remove them locally. The rule can only be checked where the
|
|
110
|
+
workspace file has been read, so it is not part of `oats-local.yaml`'s own validation.
|
|
111
|
+
- The standalone view (explicit, or a fallback when the workspace can't be read) has no workspace rules:
|
|
112
|
+
it is an individual running souls outside any organisation, so local teams apply.
|
|
113
|
+
- Verbs: `oats teams add` and `oats teams default` refuse where local teams are closed; `oats teams
|
|
114
|
+
remove` runs there, since removing local teams is the last step of committing them in the workspace.
|
|
115
|
+
`oats soul teams` is read only; its edit flags were removed (a soul's teams are a PR to the workspace).
|
|
116
|
+
|
|
117
|
+
### `soul.yaml` and `oats-membership.yaml`
|
|
118
|
+
|
|
119
|
+
Unchanged: they say nothing about teams, so a soul in a public repository carries no organisation's
|
|
120
|
+
labels. Per-soul lists in `oats-membership.yaml` and teams in `soul.yaml` were rejected for the same
|
|
121
|
+
reason v2 rejected them: labels belong to a workspace, and there should be one central place.
|
|
122
|
+
|
|
123
|
+
## Reporting (contract)
|
|
124
|
+
|
|
125
|
+
- `defaultTeam.from` (spawn preview, `inspect`, `oats souls`, readiness, `OATS_DEFAULT_TEAM_FROM`):
|
|
126
|
+
`soul`, `deployment` or `workspace`. `soul` changed meaning: it was the local `souls.default`.
|
|
127
|
+
- Every `TeamRow` gains `via`.
|
|
128
|
+
- `oats teams --json` is `teamsApi: 2`: `localTeams`, a `DefaultTeam` for the deployment, and the
|
|
129
|
+
workspace's `souls:` in place of the local `souls` block. `oats soul teams --json` is
|
|
130
|
+
`soulTeamsApi: 2`: `match` and `defaultMatch` (the `souls:` keys that applied) in place of `local` and
|
|
131
|
+
`all`. Feature `team-model-3` replaces `team-model-2`.
|
|
132
|
+
- Readiness: local-teams-closed (failure) and `team-soul-unknown` (warning) join the team items;
|
|
133
|
+
`E_TEAM_NOT_ELIGIBLE` is no longer a kernel item (a soul's default is always one of its teams); it
|
|
134
|
+
remains the provider's join refusal.
|
|
135
|
+
|
|
136
|
+
## 0.36.x → 0.38.0
|
|
137
|
+
|
|
138
|
+
0.36.1 accepted and validated `defaultTeam`, `localTeams` and `souls:` without applying them, and warned
|
|
139
|
+
(`team-model-3-migration`) about what 0.38.0 refuses, so every workspace could commit its side first, as
|
|
140
|
+
0.29.4 did for 0.30. 0.38.0 applies the keys, refuses the old shape, and retires the warning. The steps
|
|
141
|
+
for each kind of deployment are in the [0.38.0 release notes](../release-notes/v0.38.0.md).
|
|
142
|
+
|
|
143
|
+
## Out of scope
|
|
144
|
+
|
|
145
|
+
Enforcement on the aweb side (above); nested teams; the Desktop's team-editing UI (its own spec:
|
|
146
|
+
anything that writes `souls.teams` or `souls.default` goes).
|
package/docs/design/README.md
CHANGED
|
@@ -17,7 +17,11 @@ successor, with a link to its full text.
|
|
|
17
17
|
- [OKF knowledge operations](2026-09-26-okf-knowledge-operations.md): package
|
|
18
18
|
souls, triggers and automations for harvest and maintenance.
|
|
19
19
|
- [Team model v2](2026-09-27-team-model-v2.md): shared teams in the workspace,
|
|
20
|
-
local teams, the
|
|
20
|
+
local teams, live teams and the provider environment (its membership and
|
|
21
|
+
default-team parts are superseded by team model 3).
|
|
22
|
+
- [Team model 3](2026-10-02-team-model-3.md): the teams a soul may join, and
|
|
23
|
+
its default, are committed in the workspace; local teams only where the
|
|
24
|
+
workspace allows them.
|
|
21
25
|
|
|
22
26
|
The Desktop's design brief for designers is in
|
|
23
27
|
[packages/desktop/docs](../../packages/desktop/docs/design-brief.md).
|