@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/lib/teams.mjs CHANGED
@@ -1,26 +1,29 @@
1
1
  /**
2
- * Team model v2 (docs/design/2026-09-27-team-model-v2.md, option B), pure.
2
+ * Team model 3 (docs/design/2026-10-02-team-model-3.md, awebai/oats#484), pure.
3
3
  *
4
4
  * Where teams live:
5
- * - the committed oats-workspace.yaml `teams.<label> = { description?, team? }`: SHARED teams (edited
6
- * by PR). A shared team without `team` is declared but not yet created (unmapped);
7
- * - the deployment's oats-local.yaml `teams.<label> = { team, description? }`: LOCAL teams, plus
8
- * `defaultTeam: <label>`, `souls.teams: { "*" | <soul key>: [labels] }` and
9
- * `souls.default: { <soul key>: <label> }`.
10
- * A soul key is the soul's bare name, or `<package>/<soul>` for a package soul.
5
+ * - the committed oats-workspace.yaml: SHARED teams `teams.<label> = { description?, team? }` (a shared
6
+ * team without `team` is declared but not yet created: unmapped); `defaultTeam: <label>`, the
7
+ * workspace's fallback default; `localTeams: true|false` (absent: false), whether deployments may
8
+ * declare their own teams; and `souls: { <pattern>: { default?, teams?: [labels] | "any" } }`;
9
+ * - the deployment's oats-local.yaml, only when the workspace says `localTeams: true` (or there is no
10
+ * workspace file: the standalone view): LOCAL teams `teams.<label> = { team, description? }` and
11
+ * `defaultTeam: <label>`. Otherwise they are refused (E_WORKSPACE_SCHEMA reason local-teams-closed).
12
+ * A soul's key is its qualified name (teamKeyOf): `<package>/<soul>` or `<member>/<soul>`.
11
13
  *
12
- * Resolution: labels = shared ∪ local (a label in both is `team-label-collision`; the SHARED definition
13
- * wins). defaultOf(soul) = souls.default[soul] ?? defaultTeam; a soul's teams = {defaultOf} ∪
14
- * souls.teams["*"] ∪ souls.teams[soul]. An undeclared label the soul reaches is E_TEAM_UNKNOWN; a
15
- * souls.default outside the soul's teams is E_TEAM_NOT_ELIGIBLE.
14
+ * Resolution: a `souls:` pattern is the soul's key, then `<member|package>/*`, then "*"; the most specific
15
+ * one that exists gives the soul's teams outright (no merging), and the most specific one that sets a
16
+ * `default` gives its default. Default, in order: that `default` (from "soul"); else the local
17
+ * `defaultTeam` when local teams are allowed ("deployment"); else the workspace's `defaultTeam`
18
+ * ("workspace"); else none. A soul's teams: its default, plus its pattern's `teams` ("any": every shared
19
+ * team), plus every local team when local teams are allowed. A label in both files is
20
+ * `team-label-collision` (the SHARED definition wins). Workspace labels are validated when the file is
21
+ * read (lib/workspace.mjs); a local default no file declares is E_TEAM_UNKNOWN.
16
22
  *
17
- * Rows (docs/desktop-cli-api.md, Team model v2): TeamRow { label, team, default, from: shared|local },
18
- * the default first, then by label. Reports carry unmapped rows (team null); OATS_TEAMS and
19
- * instance.json carry mapped rows only. DefaultTeam { label, team, from: deployment|soul } | null.
20
- *
21
- * Team model 3 (0.38.0, awebai/oats#484) moves these choices into the committed workspace file. 0.36.x and
22
- * 0.37.x validate its keys (lib/workspace.mjs) without applying them, and warn about what 0.38.0 will refuse
23
- * (migrationProblems: team-model-3-migration).
23
+ * Rows (docs/desktop-cli-api.md, Teams): TeamRow { label, team, default, from: shared|local, via }, `via`
24
+ * ⊂ ["default", "workspace", "local"] in that order (why the soul may join it); the default first, then
25
+ * by label. Reports carry unmapped rows (team null); OATS_TEAMS and instance.json carry mapped rows only.
26
+ * DefaultTeam { label, team, from: soul|deployment|workspace } | null.
24
27
  */
25
28
  import { oatsError } from "./errors.mjs";
26
29
 
@@ -41,12 +44,16 @@ export const LOCAL_FILE = "oats-local.yaml";
41
44
  * validates its own id shape. Keep equal to docs/oats-{local,workspace}.schema.json. */
42
45
  export const TEAM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,255}$/;
43
46
  /** What 0.30 removed, named by the schema problems that replace them. */
44
- export const TEAM_MEMBERSHIP_MOVED = "team membership is local since 0.30: `oats soul teams`";
47
+ export const TEAM_MEMBERSHIP_MOVED = "a soul's teams are decided by souls: in oats-workspace.yaml (team model 3, OATS 0.38.0)";
45
48
  export const BY_TEAM_REMOVED = "byTeam was removed in 0.30: a team's provider id is teams.<label>.team (oats-workspace.yaml for a shared team, oats-local.yaml for a local one)";
46
49
 
47
50
  const UNMAPPED_FIX = "its owner runs `oats aweb setup`, then commits the id";
51
+ const CLOSED_FIX = "either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml";
48
52
 
49
- /** The deployment's teams from the committed workspace and oats-local.yaml (either may be null). */
53
+ /**
54
+ * The deployment's teams from the committed workspace and oats-local.yaml (either may be null; a null
55
+ * workspace is the standalone view, which has no workspace rules).
56
+ */
50
57
  export function teamModel(workspace, local, { workspaceKey = null } = {}) {
51
58
  const shared = new Map(), localTeams = new Map();
52
59
  for (const [label, def] of Object.entries(isObject(workspace?.teams) ? workspace.teams : {})) {
@@ -56,76 +63,124 @@ export function teamModel(workspace, local, { workspaceKey = null } = {}) {
56
63
  for (const [label, def] of Object.entries(isObject(local?.teams) ? local.teams : {})) {
57
64
  localTeams.set(label, { label, team: str(def?.team), description: str(def?.description), from: "local", at: `${LOCAL_FILE}#/teams/${pointerKey(label)}` });
58
65
  }
59
- const souls = isObject(local?.souls) ? local.souls : {};
60
- const has = (v, k) => isObject(v) && Object.hasOwn(v, k);
66
+ const standalone = !isObject(workspace);
67
+ const allowed = standalone || workspace.localTeams === true;
68
+ const localKeys = ["teams", "defaultTeam"].filter((k) => isObject(local) && Object.hasOwn(local, k));
61
69
  return {
62
70
  shared, local: localTeams,
63
71
  labels: new Map([...localTeams, ...shared]), // the committed definition wins a collision
64
- defaultTeam: str(local?.defaultTeam),
65
- souls: { teams: isObject(souls.teams) ? souls.teams : {}, default: isObject(souls.default) ? souls.default : {} },
66
- // Team model 3 (0.38.0) moves these keys; 0.36.x and 0.37.x only warn (migrationProblems). `localTeams` is
67
- // the workspace's answer, null without a workspace file (the standalone view has no workspace rules).
68
- migration: {
69
- soulKeys: ["teams", "default"].filter((k) => has(local?.souls, k)).map((k) => `souls.${k}`),
70
- teamKeys: ["teams", "defaultTeam"].filter((k) => has(local, k)),
71
- localTeams: isObject(workspace) ? workspace.localTeams === true : null,
72
- },
72
+ // true | false (the workspace's answer), null in the standalone view.
73
+ localTeams: standalone ? null : allowed,
74
+ // The oats-local.yaml keys the workspace refuses (local-teams-closed); [] when allowed.
75
+ closedKeys: allowed ? [] : localKeys,
76
+ localDefault: allowed ? str(local?.defaultTeam) : null,
77
+ workspaceDefault: standalone ? null : str(workspace.defaultTeam),
78
+ souls: !standalone && isObject(workspace.souls) ? workspace.souls : {},
79
+ soulsAt: `${workspaceKey ? `${workspaceKey}:` : ""}oats-workspace.yaml#/souls`,
73
80
  };
74
81
  }
75
82
 
76
- /** The key a soul has in souls.teams / souls.default. */
83
+ /** The key a soul has in oats-local.yaml souls.launch: its bare name, or `<package>/<soul>` for a package soul. */
77
84
  export function soulKeyOf(soulEntry) {
78
85
  return typeof soulEntry?.package === "string" && typeof soulEntry.qualifiedName === "string" ? soulEntry.qualifiedName : soulEntry.name;
79
86
  }
87
+ /** A member's name: the last segment of its repo key, without `.git` (`souls.disabled` and team keys use it). */
88
+ export function memberNameOf(key) {
89
+ return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
90
+ }
91
+ /** A soul's key in the workspace's `souls:` (team model 3): `<package>/<soul>` for a package soul,
92
+ * `<member>/<soul>` for any other (its repository's name), as `souls.disabled` qualifies it. */
93
+ export function teamKeyOf(soulEntry) {
94
+ if (typeof soulEntry?.package === "string") return typeof soulEntry.qualifiedName === "string" ? soulEntry.qualifiedName : `${soulEntry.package}/${soulEntry.name}`;
95
+ return `${memberNameOf(soulEntry?.repoKey ?? "")}/${soulEntry?.name}`;
96
+ }
97
+
98
+ /**
99
+ * What a team model v2 oats-local.yaml (`souls.teams`, `souls.default`) gave each soul, as the workspace
100
+ * `souls:` that gives the same (0.38.0's removed-key refusal prints it): "*" keeps `souls.teams["*"]`; each
101
+ * soul named gets its default and "*" ∪ its own list, since patterns never merge. A bare (member) soul
102
+ * name becomes `<member>/<soul>`, for the operator to qualify. → { souls } | null when neither key is set.
103
+ */
104
+ export function soulsReplacement(local) {
105
+ const teams = isObject(local?.souls?.teams) ? local.souls.teams : null, defaults = isObject(local?.souls?.default) ? local.souls.default : null;
106
+ if (teams === null && defaults === null) return null;
107
+ const star = labelsOf(teams?.["*"]);
108
+ const souls = {};
109
+ if (star.length) souls["*"] = { teams: star };
110
+ const named = [...Object.keys(teams ?? {}), ...Object.keys(defaults ?? {})].filter((k, i, a) => k !== "*" && a.indexOf(k) === i);
111
+ for (const k of named) {
112
+ const def = str(defaults?.[k]);
113
+ const list = [...star, ...labelsOf(teams?.[k])].filter((l, i, a) => a.indexOf(l) === i);
114
+ souls[k.includes("/") ? k : `<member>/${k}`] = { ...(def !== null ? { default: def } : {}), teams: list };
115
+ }
116
+ return { souls };
117
+ }
118
+
119
+ /** Every discovered soul's key (teamKeyOf): confirmed members' (and a standalone view's own), external and
120
+ * package souls. The typo guard (`team-soul-unknown`) checks the workspace's `souls:` keys against it. */
121
+ export function discoveredTeamKeys(discovery) {
122
+ const keys = new Set();
123
+ for (const m of discovery?.members || []) if (m.confirmed || (discovery.standalone === true && m.key === discovery.key)) for (const s of m.souls || []) keys.add(teamKeyOf(s));
124
+ for (const x of discovery?.external || []) if (x?.soul) keys.add(teamKeyOf({ ...x.soul, repoKey: x.soul.repoKey ?? x.key }));
125
+ for (const s of discovery?.packageSouls || []) keys.add(teamKeyOf(s));
126
+ return keys;
127
+ }
128
+
129
+ /** The `souls:` patterns that apply to `key`, most specific first. */
130
+ const patternsOf = (key) => (key === "*" ? ["*"] : [key, `${key.slice(0, key.lastIndexOf("/"))}/*`, "*"]);
80
131
 
81
132
  const unknown = (label, at) => fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared (${at}): declare it with \`oats teams add\`, or in oats-workspace.yaml teams: for a shared team`, { label, at });
133
+ const closedMessage = (keys) => `${LOCAL_FILE} declares ${keys.join(", ")}, but oats-workspace.yaml does not allow local teams (localTeams: true): ${CLOSED_FIX}`;
134
+ /** Whether `e` refuses a soul's TEAMS (an undeclared label, or local teams the workspace does not allow):
135
+ * readiness reports it as a team item, a home falls back to its recorded teams. */
136
+ export const isTeamRefusal = (e) => e?.code === "E_TEAM_UNKNOWN" || (e?.code === "E_WORKSPACE_SCHEMA" && e?.details?.reason === "local-teams-closed");
137
+ /** Whether `e` refuses oats-local.yaml for the team keys 0.38.0 removed (`souls.teams` / `souls.default`):
138
+ * a reader that otherwise falls back to a home's recorded teams must answer it instead. */
139
+ export const isRemovedTeamKeys = (e) => e?.code === "E_WORKSPACE_SCHEMA" && Array.isArray(e?.details?.problems)
140
+ && e.details.problems.some((p) => p?.reason === "removed-key" && /^\/souls\/(teams|default)$/.test(p.path));
141
+ /** The refusal of local teams the workspace does not allow (E_WORKSPACE_SCHEMA reason local-teams-closed). */
142
+ export const localTeamsClosed = (keys, { verb = null } = {}) => fail("E_WORKSPACE_SCHEMA",
143
+ verb ? `${verb} writes ${keys.join(", ")} in ${LOCAL_FILE}, but oats-workspace.yaml does not allow local teams (localTeams: true): ${CLOSED_FIX}` : closedMessage(keys),
144
+ { reason: "local-teams-closed", path: LOCAL_FILE, keys: [...keys] });
82
145
 
83
146
  /**
84
- * One soul's teams here (key "*": the deployment default + souls.teams["*"]).
85
- * → { key, defaultTeam: DefaultTeam | null, teams: [TeamRow + via] } | throws E_TEAM_UNKNOWN / E_TEAM_NOT_ELIGIBLE.
86
- * `via` ⊂ ["default", "*", "soul"], in that order: why the soul has the team.
147
+ * One soul's teams here (key "*": what the "*" pattern gives).
148
+ * → { key, match, defaultMatch, defaultTeam: DefaultTeam | null, teams: [TeamRow] }; `match` / `defaultMatch`
149
+ * are the `souls:` keys its teams / its default come from (null: none). Throws E_WORKSPACE_SCHEMA
150
+ * (local-teams-closed) / E_TEAM_UNKNOWN.
87
151
  */
88
152
  export function soulTeams(model, key) {
89
- const known = (label, at) => { if (!model.labels.has(label)) throw unknown(label, at); };
90
- if (model.defaultTeam !== null) known(model.defaultTeam, `${LOCAL_FILE}#/defaultTeam`);
91
- const star = labelsOf(model.souls.teams["*"]);
92
- star.forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/*/${i}`));
93
- const own = key === "*" ? [] : labelsOf(model.souls.teams[key]);
94
- own.forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/${pointerKey(key)}/${i}`));
95
- const override = key === "*" ? null : str(model.souls.default[key]);
96
- if (override !== null) {
97
- const at = `${LOCAL_FILE}#/souls/default/${pointerKey(key)}`;
98
- known(override, at);
99
- if (override !== model.defaultTeam && !star.includes(override) && !own.includes(override)) {
100
- throw fail("E_TEAM_NOT_ELIGIBLE", `souls.default.${key} is ${JSON.stringify(override)}, which is not one of ${key}'s teams here — add it first (\`oats soul teams ${key} --add ${override}\`)`, { soul: key, label: override, at });
101
- }
102
- }
103
- const defaultLabel = override ?? model.defaultTeam;
153
+ if (model.closedKeys.length) throw localTeamsClosed(model.closedKeys);
154
+ const patterns = patternsOf(key);
155
+ const match = patterns.find((p) => isObject(model.souls[p])) ?? null;
156
+ const defaultMatch = patterns.find((p) => isObject(model.souls[p]) && typeof model.souls[p].default === "string") ?? null;
157
+ if (model.localDefault !== null && !model.labels.has(model.localDefault)) throw unknown(model.localDefault, `${LOCAL_FILE}#/defaultTeam`);
158
+ const [defaultLabel, from] = defaultMatch !== null ? [model.souls[defaultMatch].default, "soul"]
159
+ : model.localDefault !== null ? [model.localDefault, "deployment"]
160
+ : model.workspaceDefault !== null ? [model.workspaceDefault, "workspace"] : [null, null];
104
161
  const via = new Map();
105
- const add = (label, why) => { const v = via.get(label) ?? []; if (!v.includes(why)) v.push(why); via.set(label, v); };
162
+ const add = (label, why) => { if (!model.labels.has(label)) return; const v = via.get(label) ?? []; if (!v.includes(why)) v.push(why); via.set(label, v); };
106
163
  if (defaultLabel !== null) add(defaultLabel, "default");
107
- for (const l of star) add(l, "*");
108
- for (const l of own) add(l, "soul");
164
+ const listed = match === null ? [] : model.souls[match].teams === "any" ? [...model.shared.keys()] : labelsOf(model.souls[match].teams);
165
+ for (const l of listed) add(l, "workspace");
166
+ if (model.localTeams !== false) for (const l of model.local.keys()) add(l, "local");
109
167
  const teams = [...via].map(([label, v]) => {
110
168
  const d = model.labels.get(label);
111
169
  return { label, team: d.team, default: label === defaultLabel, from: d.from, via: v };
112
170
  }).sort((a, b) => (b.default - a.default) || byCodepoint(a.label, b.label));
113
- const defaultTeam = defaultLabel === null ? null : { label: defaultLabel, team: model.labels.get(defaultLabel).team, from: override !== null ? "soul" : "deployment" };
114
- return { key, defaultTeam, teams };
171
+ const defaultTeam = defaultLabel === null || !model.labels.has(defaultLabel) ? null : { label: defaultLabel, team: model.labels.get(defaultLabel).team, from };
172
+ return { key, match, defaultMatch, defaultTeam, teams };
115
173
  }
116
174
 
117
- /** TeamRows as the reports carry them (unmapped included, no `via`). */
118
- export const reportRows = (teams) => (teams || []).map(({ label, team, default: d, from }) => ({ label, team, default: d, from }));
175
+ /** TeamRows as the reports carry them (unmapped included). A row a home recorded before team model 3 has
176
+ * no `via`, and is reported as recorded (its eligibility is not guessed). */
177
+ export const reportRows = (teams) => (teams || []).map(({ label, team, default: d, from, via }) => ({ label, team, default: d, from, ...(Array.isArray(via) ? { via: [...via] } : {}) }));
119
178
  /** TeamRows as OATS_TEAMS and instance.json carry them: mapped only. */
120
179
  export const envRows = (teams) => reportRows(teams).filter((r) => r.team !== null);
121
180
 
122
181
  /** Every reference to `label` in oats-local.yaml, in written order (`oats teams remove` refuses on any). */
123
182
  export function teamReferences(model, label) {
124
- const refs = [];
125
- if (model.defaultTeam === label) refs.push("defaultTeam");
126
- for (const [key, list] of Object.entries(model.souls.teams)) if (labelsOf(list).includes(label)) refs.push(`souls.teams:${key}`);
127
- for (const [key, l] of Object.entries(model.souls.default)) if (l === label) refs.push(`souls.default:${key}`);
128
- return refs;
183
+ return model.localDefault === label ? ["defaultTeam"] : [];
129
184
  }
130
185
 
131
186
  const collisionProblem = (label, shared, local) => ({
@@ -135,64 +190,48 @@ const collisionProblem = (label, shared, local) => ({
135
190
  message: `team ${label} is declared in both oats-workspace.yaml (shared) and oats-local.yaml (local); the shared definition wins`,
136
191
  fix: "rename the local label in oats-local.yaml",
137
192
  });
138
- const unmappedProblem = (d, isDefault) => isDefault
139
- ? { code: "team-unmapped", label: d.label, default: true, severity: "failure", at: d.at, message: `the default team ${d.label} has no provider id yet`, fix: `${UNMAPPED_FIX}; or choose another default with \`oats teams default\`` }
193
+ /** Where another default is chosen: where this one comes from (`t` is soulTeams' answer). */
194
+ const otherDefault = (t) => t.defaultTeam?.from === "soul" ? `in the souls: entry ${t.defaultMatch} of oats-workspace.yaml`
195
+ : t.defaultTeam?.from === "workspace" ? "defaultTeam in oats-workspace.yaml" : "with `oats teams default`";
196
+ const unmappedProblem = (d, t) => d.label === t.defaultTeam?.label
197
+ ? { code: "team-unmapped", label: d.label, default: true, severity: "failure", at: d.at, message: `the default team ${d.label} has no provider id yet`,
198
+ fix: `${UNMAPPED_FIX}; or choose another ${t.defaultTeam.from === "workspace" ? "" : "default "}${otherDefault(t)}` }
140
199
  : { code: "team-unmapped", label: d.label, default: false, severity: "warning", at: d.at, message: `shared team ${d.label} has no provider id yet`, fix: UNMAPPED_FIX };
141
- const unconfiguredProblem = () => ({ code: "E_TEAM_UNCONFIGURED", severity: "failure", message: "no teams configured: run `oats aweb setup`", fix: "run `oats aweb setup` (it creates the teams and sets the default), or `oats teams add <label> --team <id>`" });
200
+ /** No default: committed in the workspace file, or recorded here where local teams are allowed (the standalone
201
+ * view has only the local way). */
202
+ const unconfiguredProblem = (model) => ({ code: "E_TEAM_UNCONFIGURED", severity: "failure", message: "no teams configured: run `oats aweb setup`",
203
+ fix: model.localTeams === null ? "run `oats aweb setup` (it creates the teams and sets the default), or `oats teams add <label> --team <id>`"
204
+ : `run \`oats aweb setup\` to create a team, then commit it in oats-workspace.yaml as defaultTeam (or as a soul's default in souls:)${model.localTeams ? ", or record it here with `oats teams add <label> --team <id>`" : ""}` });
142
205
  const refusalProblem = (e) => ({ code: e.code, ...e.details, severity: "failure", message: e.message,
143
- fix: e.code === "E_TEAM_UNKNOWN" ? "declare the team (`oats teams add`), or remove the reference" : "add the label to the soul's teams (`oats soul teams … --add`), or clear its default (`--clear-default`)" });
144
-
145
- /** The fields of a team-model-3-migration problem other than code/severity/message/fix, as every
146
- * surface (oats teams, readiness, doctor) carries them. */
147
- export const MIGRATION_CODE = "team-model-3-migration";
148
- /**
149
- * The team-model-3-migration warnings (0.36.x and 0.37.x; 0.38.0 refuses what they name): `local-soul-teams` when
150
- * oats-local.yaml has souls.teams / souls.default, `local-teams-closed` when it declares teams /
151
- * defaultTeam and the workspace file does not say `localTeams: true` (never without a workspace file).
152
- */
153
- export function migrationProblems(model) {
154
- const m = model.migration, problems = [];
155
- if (m.soulKeys.length) problems.push({ code: MIGRATION_CODE, severity: "warning", condition: "local-soul-teams", keys: [...m.soulKeys],
156
- message: `oats-local.yaml ${m.soulKeys.join(", ")}: OATS 0.38.0 refuses ${m.soulKeys.length > 1 ? "these keys" : "this key"}; which teams a soul may join, and its default, move to souls: in oats-workspace.yaml`,
157
- fix: `commit the same choices as souls: entries in oats-workspace.yaml ("*" or <member|package>/<soul>: { default, teams }), then remove ${m.soulKeys.join(" and ")} from oats-local.yaml` });
158
- if (m.teamKeys.length && m.localTeams === false) problems.push(localTeamsClosedProblem(m.teamKeys));
159
- return problems;
160
- }
161
- /** `local-teams-closed` for the oats-local.yaml `keys` found (doctor builds it from its offline read). */
162
- export const localTeamsClosedProblem = (keys) => ({ code: MIGRATION_CODE, severity: "warning", condition: "local-teams-closed", keys: [...keys],
163
- message: `oats-local.yaml declares ${keys.join(", ")}, but oats-workspace.yaml does not say localTeams: true: OATS 0.38.0 refuses local teams and a local defaultTeam unless the workspace allows them`,
164
- fix: "either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml" });
206
+ fix: "declare the team (`oats teams add`), or choose another default (`oats teams default`)" });
207
+ const soulUnknownProblem = (key, at) => ({ code: "team-soul-unknown", severity: "warning", key, at,
208
+ message: `souls: ${key} names no soul of this workspace (a pattern is "*" or <member|package>/*)`, fix: "correct the key to a soul's qualified name (oats souls lists them), or remove it" });
209
+ /** local-teams-closed as a problem (oats teams, readiness, doctor): the refusal's facts, never thrown. */
210
+ export const localTeamsClosedProblem = (keys) => ({ code: "E_WORKSPACE_SCHEMA", severity: "failure", condition: "local-teams-closed", path: LOCAL_FILE, keys: [...keys],
211
+ message: closedMessage(keys), fix: CLOSED_FIX });
165
212
 
166
213
  /**
167
- * Readiness problems. Without `key`: the deployment's (`oats teams`): collisions, unmapped shared
168
- * teams (a failure when it is `defaultTeam`), every unknown reference, every ineligible
169
- * souls.default. With `key`: only what concerns that soul (`default` marks ITS default).
170
- * `messaging`: a messaging layer is active, so no default is E_TEAM_UNCONFIGURED.
214
+ * Readiness problems. Without `key`: the deployment's (`oats teams`): collisions, unmapped shared teams
215
+ * (a failure when it is the default a soul without a souls: default gets), an unknown local default.
216
+ * With `key`: only what concerns that soul (`default` marks ITS default). Local teams the workspace does
217
+ * not allow are the one problem either way. `messaging`: a messaging layer is active, so no default is
218
+ * E_TEAM_UNCONFIGURED. `soulKeys` (discoveredTeamKeys; null when the souls are not known): a `souls:` key
219
+ * that is neither a pattern nor one of them is `team-soul-unknown` (a typo guard, either way).
171
220
  */
172
- export function teamProblems(model, { key = null, messaging = false } = {}) {
221
+ export function teamProblems(model, { key = null, messaging = false, soulKeys = null } = {}) {
222
+ if (model.closedKeys.length) return [localTeamsClosedProblem(model.closedKeys)];
173
223
  const problems = [];
174
- if (key !== null) {
175
- let t;
176
- try { t = soulTeams(model, key); }
177
- catch (e) { if (e.code === "E_TEAM_UNKNOWN" || e.code === "E_TEAM_NOT_ELIGIBLE") return [refusalProblem(e), ...migrationProblems(model)]; throw e; }
178
- for (const r of t.teams) if (model.shared.has(r.label) && model.local.has(r.label)) problems.push(collisionProblem(r.label, model.shared.get(r.label), model.local.get(r.label)));
179
- for (const r of t.teams) if (r.team === null) problems.push(unmappedProblem(model.labels.get(r.label), r.default));
180
- if (messaging && t.defaultTeam === null) problems.push(unconfiguredProblem());
181
- return [...problems, ...migrationProblems(model)];
182
- }
183
- const labels = [...model.labels.keys()].sort(byCodepoint);
224
+ // An undeclared local default is reported; the rest is judged as if it were not set.
225
+ if (model.localDefault !== null && !model.labels.has(model.localDefault)) problems.push(refusalProblem(unknown(model.localDefault, `${LOCAL_FILE}#/defaultTeam`)));
226
+ const t = soulTeams(problems.length ? { ...model, localDefault: null } : model, key ?? "*");
227
+ const labels = key !== null ? t.teams.map((r) => r.label) : [...model.labels.keys()].sort(byCodepoint);
184
228
  for (const label of labels) if (model.shared.has(label) && model.local.has(label)) problems.push(collisionProblem(label, model.shared.get(label), model.local.get(label)));
185
- for (const label of labels) { const d = model.labels.get(label); if (d.team === null) problems.push(unmappedProblem(d, label === model.defaultTeam)); }
186
- const known = (label, at) => { if (!model.labels.has(label)) problems.push(refusalProblem(unknown(label, at))); };
187
- if (model.defaultTeam !== null) known(model.defaultTeam, `${LOCAL_FILE}#/defaultTeam`);
188
- for (const [k, list] of Object.entries(model.souls.teams)) labelsOf(list).forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/${pointerKey(k)}/${i}`));
189
- for (const [k, l] of Object.entries(model.souls.default)) if (typeof l === "string") known(l, `${LOCAL_FILE}#/souls/default/${pointerKey(k)}`);
190
- for (const [k, l] of Object.entries(model.souls.default)) {
191
- if (typeof l !== "string" || !model.labels.has(l)) continue;
192
- try { soulTeams(model, k); } catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE" && e.details.soul === k) problems.push(refusalProblem(e)); else if (e.code !== "E_TEAM_UNKNOWN") throw e; }
229
+ for (const label of labels) { const d = model.labels.get(label); if (d.team === null) problems.push(unmappedProblem(d, t)); }
230
+ if (messaging && t.defaultTeam === null && model.localDefault === null) problems.push(unconfiguredProblem(model));
231
+ if (soulKeys !== null) for (const k of Object.keys(model.souls).sort(byCodepoint)) {
232
+ if (k !== "*" && !k.endsWith("/*") && !soulKeys.has(k)) problems.push(soulUnknownProblem(k, `${model.soulsAt}/${pointerKey(k)}`));
193
233
  }
194
- if (messaging && model.defaultTeam === null) problems.push(unconfiguredProblem());
195
- return [...problems, ...migrationProblems(model)];
234
+ return problems;
196
235
  }
197
236
 
198
237
  /**
package/lib/workspace.mjs CHANGED
@@ -18,7 +18,8 @@ import { oatsError } from "./errors.mjs";
18
18
  import * as defaultRemote from "./remote.mjs";
19
19
  import { bindRemote, classifyPackageValue, lockedPackageRef } from "./packages.mjs";
20
20
  import { manifestContractProblems } from "./capability-contract.mjs";
21
- import { BY_TEAM_REMOVED, TEAM_MEMBERSHIP_MOVED } from "./teams.mjs";
21
+ import YAML from "yaml";
22
+ import { BY_TEAM_REMOVED, TEAM_MEMBERSHIP_MOVED, soulsReplacement } from "./teams.mjs";
22
23
 
23
24
  /* ───────────────────────────── errors ─────────────────────────────────── */
24
25
 
@@ -261,7 +262,7 @@ function sharedLabelProblems(value, problems) {
261
262
  }
262
263
  export function validateMembership(value) { return withRemovedKeys(validateAgainst(schemaFor("membership"), value), value, REMOVED_KEYS.membership); }
263
264
 
264
- /** Keys 0.30 removed (team model v2): each is a schema problem naming its replacement, in place of
265
+ /** Keys 0.30 (team model v2) and 0.38.0 (team model 3) removed: each is a schema problem naming its replacement, in place of
265
266
  * the schema's generic "unexpected property". `at(value)` → the JSON pointers where it appears. */
266
267
  const REMOVED_KEYS = {
267
268
  workspace: [
@@ -270,6 +271,11 @@ const REMOVED_KEYS = {
270
271
  ],
271
272
  membership: [{ at: (v) => (isObject(v) && Object.hasOwn(v, "team") ? ["/team"] : []), message: TEAM_MEMBERSHIP_MOVED }],
272
273
  soul: [{ at: (v) => (isObject(v) && Object.hasOwn(v, "team") ? ["/team"] : []), message: TEAM_MEMBERSHIP_MOVED }],
274
+ // 0.38.0 (team model 3): a soul's teams and default are committed in the workspace's souls:.
275
+ local: [
276
+ { at: (v) => (isObject(v?.souls) && Object.hasOwn(v.souls, "teams") ? ["/souls/teams"] : []), message: "souls.teams was removed in 0.38.0 (team model 3): which teams a soul may join is souls: in oats-workspace.yaml" },
277
+ { at: (v) => (isObject(v?.souls) && Object.hasOwn(v.souls, "default") ? ["/souls/default"] : []), message: "souls.default was removed in 0.38.0 (team model 3): a soul's default team is souls: in oats-workspace.yaml (default:)" },
278
+ ],
273
279
  };
274
280
  function withRemovedKeys(problems, value, removed) {
275
281
  const found = removed.flatMap((r) => r.at(value).map((path) => ({ path, reason: "removed-key", message: r.message })));
@@ -316,7 +322,7 @@ function fromProblems(remote, capabilities, path, problems, { here }) {
316
322
  }
317
323
  /** Schema + domain problems for an oats-local.yaml value: settings[<cap>] may not carry the removed `byTeam` key. */
318
324
  export function validateLocal(value) {
319
- const problems = validateAgainst(schemaFor("local"), value);
325
+ const problems = withRemovedKeys(validateAgainst(schemaFor("local"), value), value, REMOVED_KEYS.local);
320
326
  if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
321
327
  // runtime → harness (0.27.0, lead call 6): `runtime`, the pre-0.27 name, is still read; both,
322
328
  // disagreeing, are refused.
@@ -361,8 +367,12 @@ function schemaError(kind, origin, problems, value) {
361
367
  const where = origin.repoKey ? `${origin.repoKey}@${(origin.commit || "").slice(0, 12)}:${origin.path}` : origin.path;
362
368
  // A single-cause refusal (reserved-key, …) surfaces its reason on details for callers that branch on it.
363
369
  const reasons = new Set(problems.map((p) => p.reason).filter(Boolean));
364
- return fail("E_WORKSPACE_SCHEMA", `${FILE_KINDS[kind].file} at ${where} is invalid${schemaHint(kind, value)}: ${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, {
365
- path: origin.path, repoKey: origin.repoKey, commit: origin.commit, problems, ...(reasons.size === 1 ? { reason: [...reasons][0] } : {}),
370
+ // Team model 3: the refused souls.teams / souls.default come with the souls: that replaces them.
371
+ const moved = kind === "local" && problems.some((p) => p.reason === "removed-key" && /^\/souls\/(teams|default)$/.test(p.path)) ? soulsReplacement(value) : null;
372
+ const replacement = moved ? YAML.stringify(moved, { lineWidth: 0 }) : null;
373
+ return fail("E_WORKSPACE_SCHEMA", `${FILE_KINDS[kind].file} at ${where} is invalid${schemaHint(kind, value)}: ${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`
374
+ + (replacement ? `; commit this in oats-workspace.yaml (<member> is the soul's member repository name, as souls.disabled names it), then remove souls.teams and souls.default from oats-local.yaml:\n${replacement}` : ""), {
375
+ path: origin.path, repoKey: origin.repoKey, commit: origin.commit, problems, ...(reasons.size === 1 ? { reason: [...reasons][0] } : {}), ...(replacement ? { replacement } : {}),
366
376
  });
367
377
  }
368
378
 
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.18.1",
11
+ "ref": "v1.20.0",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "oats.framework": {
35
35
  "url": "https://github.com/awebai/oats.git",
36
- "ref": "oats-framework/v1.4.3",
36
+ "ref": "oats-framework/v1.5.0",
37
37
  "path": "oats-package"
38
38
  }
39
39
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -60,7 +60,7 @@ members:
60
60
  - git:github.com/acme/agents # the host is a member too
61
61
  - git:github.com/acme/platform
62
62
  packages:
63
- oats.framework: v1.4.3 # bare versions resolve through the official catalog
63
+ oats.framework: v1.5.0 # bare versions resolve through the official catalog
64
64
  oats.okf: v4.1.1
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }
@@ -135,18 +135,29 @@ membership.
135
135
  ## 6. Give the deployment a team (with messaging)
136
136
 
137
137
  If a messaging capability fills the messaging slot, every instance lives in a
138
- team, and readiness fails with `E_TEAM_UNCONFIGURED` until this deployment has
139
- a default team. Create the team with the messaging provider (its own skills say
140
- how), then record it:
138
+ team, and readiness fails with `E_TEAM_UNCONFIGURED` until there is a default
139
+ team. Create the team with the messaging provider (its own skills say how),
140
+ then commit it in `oats-workspace.yaml` with its provider id, and make it the
141
+ default:
142
+
143
+ ```yaml
144
+ teams:
145
+ research: { team: <provider team id> }
146
+ defaultTeam: research
147
+ ```
148
+
149
+ Push, then sync and check:
141
150
 
142
151
  ```bash
143
- oats teams add research --team <provider team id> # a local team; the first one becomes the default
144
- oats teams
152
+ oats sync --dir <dir>
153
+ oats teams --dir <dir>
145
154
  ```
146
155
 
147
- A team the whole organisation uses is committed in `oats-workspace.yaml` as
148
- `teams.<label>` with its provider id; which souls join which team on this
149
- machine is `oats soul teams` (`/oats-teams`, in `oats.setup`).
156
+ The teams an instance may join, and its default, are the organisation's
157
+ decision, committed in this file: by default a soul joins its default team
158
+ only, and `souls:` entries open other teams to a soul. A deployment declares
159
+ teams of its own (`oats teams add`) only when the workspace says
160
+ `localTeams: true` (`/oats-teams`, in `oats.setup`).
150
161
 
151
162
  ## 7. Spawn the first soul
152
163