@awebai/oats 0.37.0 → 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 +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/first-team.md +14 -7
- package/docs/integrations.md +1 -1
- 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/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/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/docs/packages.md
CHANGED
|
@@ -51,7 +51,7 @@ packages:
|
|
|
51
51
|
- **Bare version** (`v4.1.1`, `4.1.1`, `1.0.0-rc.1`): the id is looked up in
|
|
52
52
|
the official catalog — `package-catalog.json` in the `oats` repo, or the file
|
|
53
53
|
named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
|
|
54
|
-
convention (`v4.1.1` or `oats-framework/v1.
|
|
54
|
+
convention (`v4.1.1` or `oats-framework/v1.5.0`) and the payload path. An id
|
|
55
55
|
the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
|
|
56
56
|
a package outside the catalog"). The catalog is the reviewed official list
|
|
57
57
|
([official-catalog.md](official-catalog.md)) and the only way a
|
|
@@ -74,9 +74,9 @@ members:
|
|
|
74
74
|
- git:github.com/acme/agents
|
|
75
75
|
- git:github.com/acme/platform
|
|
76
76
|
packages:
|
|
77
|
-
oats.framework: v1.
|
|
77
|
+
oats.framework: v1.5.0
|
|
78
78
|
oats.okf: v4.1.1
|
|
79
|
-
oats.aweb: v1.
|
|
79
|
+
oats.aweb: v1.19.0
|
|
80
80
|
teams:
|
|
81
81
|
platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
|
|
82
82
|
defaults:
|
|
@@ -134,7 +134,7 @@ Declaring a package in the workspace's `packages:` is the trust decision
|
|
|
134
134
|
## `oats package add | remove`
|
|
135
135
|
|
|
136
136
|
```bash
|
|
137
|
-
oats package add oats.aweb v1.
|
|
137
|
+
oats package add oats.aweb v1.19.0 # a catalog version
|
|
138
138
|
oats package add acme.tools git:github.com/acme/tools@v0.4.0
|
|
139
139
|
oats package remove acme.tools
|
|
140
140
|
```
|
|
@@ -247,8 +247,9 @@ oats-package/
|
|
|
247
247
|
souls; otherwise `E_SOUL_AMBIGUOUS` names each qualified form
|
|
248
248
|
(`details.qualified`). A member soul's qualified form is `<member name>/<soul>`.
|
|
249
249
|
- **Resolved** like any soul: the workspace defaults apply, `off` and
|
|
250
|
-
`<slot>: none` work, its teams
|
|
251
|
-
|
|
250
|
+
`<slot>: none` work, its teams are keyed `<package>/<soul>` (or `<package>/*`)
|
|
251
|
+
in the workspace's `souls:` (unlisted, it joins its default only), and
|
|
252
|
+
`from: here` means **this package** at the locked commit
|
|
252
253
|
(a capability it does not provide is `E_CAPABILITY_MISSING`).
|
|
253
254
|
- **Spawned** at the locked commit: the soul is fetched into the per-commit
|
|
254
255
|
soul cache and its digest must equal the lock's (`E_PACKAGE_INTEGRITY
|
|
@@ -332,11 +333,11 @@ A soul that names one of the package's capabilities with
|
|
|
332
333
|
"policy": "docs/official-catalog.md",
|
|
333
334
|
"packages": {
|
|
334
335
|
"oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.1.1", "path": "oats-package" },
|
|
335
|
-
"oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.
|
|
336
|
+
"oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.5.0", "path": "oats-package" }
|
|
336
337
|
}
|
|
337
338
|
}
|
|
338
339
|
```
|
|
339
340
|
|
|
340
|
-
`ref` carries the tag convention: a workspace's `oats.framework: v1.
|
|
341
|
-
resolves to tag `oats-framework/v1.
|
|
341
|
+
`ref` carries the tag convention: a workspace's `oats.framework: v1.5.0`
|
|
342
|
+
resolves to tag `oats-framework/v1.5.0`. Resolving through the catalog never
|
|
342
343
|
advances a lock by itself: `oats sync` does, and says so.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# OATS 0.38.0
|
|
2
|
+
|
|
3
|
+
## Changed (breaking)
|
|
4
|
+
|
|
5
|
+
- **The Desktop accepts OATS CLIs `>=0.25.8 <0.39.0`**, so it runs against
|
|
6
|
+
this release's kernel. Install the CLI and the Desktop 0.38.0 together: the
|
|
7
|
+
Desktop 0.37.x refuses a 0.38 CLI.
|
|
8
|
+
|
|
9
|
+
- **Team model 3: the teams a soul may join, and its default, are committed
|
|
10
|
+
in the workspace** (awebai/oats#484; feature `team-model-3` replaces
|
|
11
|
+
`team-model-2`). An instance in two teams is a bridge between them, so which
|
|
12
|
+
teams an organisation's instances may be in is now its decision, committed
|
|
13
|
+
in `oats-workspace.yaml` and closed by default. This prevents accidental
|
|
14
|
+
joins and makes the intended team set reviewable in git; it does not stop a
|
|
15
|
+
deliberate relay by someone holding credentials for two teams (see
|
|
16
|
+
docs/design/2026-10-02-team-model-3.md).
|
|
17
|
+
- `oats-workspace.yaml` `defaultTeam`, `localTeams` and `souls:` (accepted
|
|
18
|
+
since 0.36.1) now apply. `souls:` keys are `"*"`, `<member|package>/*` and
|
|
19
|
+
`<member|package>/<soul>`; the most specific key gives a soul's teams
|
|
20
|
+
outright (lists never merge), and the most specific key that sets one
|
|
21
|
+
gives its default: `a/*: {default: security}` and `a/x: {teams: [docs]}`
|
|
22
|
+
give `a/x` the default `security` and the teams `[docs]` only. `teams:
|
|
23
|
+
any` is every shared team. A soul no key matches has its default only,
|
|
24
|
+
package souls included.
|
|
25
|
+
- A soul's default team is its `souls:` default; else the deployment's
|
|
26
|
+
`oats-local.yaml` `defaultTeam`, only when the workspace says
|
|
27
|
+
`localTeams: true`; else the workspace's `defaultTeam`; else none.
|
|
28
|
+
`defaultTeam.from` and `OATS_DEFAULT_TEAM_FROM` are `soul`, `deployment`
|
|
29
|
+
or `workspace`; `soul` now means the workspace's `souls:` default (it was
|
|
30
|
+
the local `souls.default`).
|
|
31
|
+
- A soul may join its default, its `souls:` teams and, with
|
|
32
|
+
`localTeams: true`, every local team. Every team row carries `via`
|
|
33
|
+
(`default`, `workspace`, `local`), in reports, `OATS_TEAMS` and
|
|
34
|
+
`instance.json`.
|
|
35
|
+
- `oats-local.yaml` `souls.teams` and `souls.default` are refused
|
|
36
|
+
(`E_WORKSPACE_SCHEMA`, reason `removed-key`). The refusal names each key
|
|
37
|
+
and prints the `souls:` to commit instead (`details.replacement`); `oats
|
|
38
|
+
doctor` answers with it.
|
|
39
|
+
- `oats-local.yaml` `teams` and `defaultTeam` are refused unless the
|
|
40
|
+
workspace says `localTeams: true` (`E_WORKSPACE_SCHEMA`, reason
|
|
41
|
+
`local-teams-closed`, naming both fixes). Spawn, preview and inspect
|
|
42
|
+
refuse; `oats teams`, readiness and `oats doctor` (offline, from the cached
|
|
43
|
+
workspace file) report it as a failure. The standalone view allows them.
|
|
44
|
+
- `oats teams add` and `oats teams default` are refused where local teams
|
|
45
|
+
are closed; `oats teams remove` still works there. `oats soul teams` is
|
|
46
|
+
read only: `--add`, `--remove`, `--default` and `--clear-default` are
|
|
47
|
+
`E_BAD_ARGS`, naming `souls:` in `oats-workspace.yaml`.
|
|
48
|
+
- `oats teams --json` is `teamsApi: 2` (`localTeams`, a `DefaultTeam`, the
|
|
49
|
+
workspace's `souls:`) and now discovers the workspace's souls; `oats soul
|
|
50
|
+
teams --json` is `soulTeamsApi: 2` (`key` is the qualified soul key,
|
|
51
|
+
`match` and `defaultMatch` name the `souls:` keys that applied).
|
|
52
|
+
- New readiness items: local-teams-closed (failure) and `team-soul-unknown`
|
|
53
|
+
(a warning for a `souls:` key that names no soul of the workspace).
|
|
54
|
+
`team-model-3-migration` is retired, and the kernel no longer reports
|
|
55
|
+
`E_TEAM_NOT_ELIGIBLE` (a soul's default is always eligible).
|
|
56
|
+
- Shapes: docs/desktop-cli-api.md, under Teams.
|
|
57
|
+
- **The Desktop follows team model 3.** With a 0.38 CLI (feature `team-model-3`) the Workspace ›
|
|
58
|
+
Teams page shows the workspace's `souls:` read only, and offers Add and Make default for local teams
|
|
59
|
+
only where the workspace says `localTeams: true`; where it doesn't, a local team still in
|
|
60
|
+
`oats-local.yaml` keeps Remove under the kernel's local-teams-closed message. A soul's *Teams here* is
|
|
61
|
+
read only: it says where the soul's default and teams come from and that they change as `souls:` in
|
|
62
|
+
`oats-workspace.yaml` (a PR to the workspace file). The Desktop never sends the removed `oats soul
|
|
63
|
+
teams` edit flags, and a 0.37 or older CLI keeps the team model v2 views.
|
|
64
|
+
- **oats.aweb 1.19.0** (catalog and workspace pin, and the bundled mirror),
|
|
65
|
+
for team model 3. A default team that comes from the workspace's
|
|
66
|
+
`defaultTeam` is reported as `from: "workspace"` in `oats aweb teams --json`
|
|
67
|
+
and the recorded meta (1.18 reported it as `deployment`). `oats aweb setup
|
|
68
|
+
--create` asks the kernel whether the workspace allows local teams before it
|
|
69
|
+
creates anything; where it does not, it creates the team and its per-team
|
|
70
|
+
root, records no local team, and prints the `teams:` entry (and
|
|
71
|
+
`defaultTeam:`) to commit in `oats-workspace.yaml`, or the `localTeams: true`
|
|
72
|
+
alternative. `--username` and setup's other advice say the same where local
|
|
73
|
+
teams are closed, and an unmapped default's remedy names `defaultTeam:` in
|
|
74
|
+
the workspace file. Joining stays limited to the eligible `OATS_TEAMS` rows.
|
|
75
|
+
|
|
76
|
+
- **oats.framework 1.5.0** (oats.core 2.3.0, oats.setup 2.3.0; catalog and
|
|
77
|
+
workspace pin `oats-framework/v1.5.0`): the skills
|
|
78
|
+
describe team model 3. `oats-teams`, `oats-workspace-config`,
|
|
79
|
+
`oats-setup-model`, `oats-onboarding` and the setup inject say a soul's teams
|
|
80
|
+
and default are committed in `souls:` of `oats-workspace.yaml`, local teams
|
|
81
|
+
only with `localTeams: true`; `oats-operate` and `oats-souls` say where an
|
|
82
|
+
instance's teams come from.
|
|
83
|
+
|
|
84
|
+
## Upgrade
|
|
85
|
+
|
|
86
|
+
Before upgrading a deployment, its workspace must say what its
|
|
87
|
+
`oats-local.yaml` said. 0.36.1 and 0.37.x already accept the workspace keys,
|
|
88
|
+
and their `team-model-3-migration` warnings list what each deployment has to
|
|
89
|
+
move.
|
|
90
|
+
|
|
91
|
+
1. **In the workspace file** (a PR, once everyone who reads it runs 0.36.1 or
|
|
92
|
+
later):
|
|
93
|
+
- commit `souls:` entries for what the deployments' `souls.teams` and
|
|
94
|
+
`souls.default` said. A v2 soul's teams were its default, `"*"`'s teams
|
|
95
|
+
and its own, and patterns never merge, so a soul's own entry lists
|
|
96
|
+
`"*"`'s teams too. Qualify bare soul names with their member's name
|
|
97
|
+
(`<member>/<soul>`, as `souls.disabled` names it). Running 0.38.0 against
|
|
98
|
+
an unmigrated `oats-local.yaml` prints this snippet for you;
|
|
99
|
+
- commit `defaultTeam: <label>` when the deployments' default is a shared
|
|
100
|
+
team;
|
|
101
|
+
- for local (personal) teams and a local default, choose: (a) keep them
|
|
102
|
+
personal, with `localTeams: true`; or (b) commit them in `teams:` (and the
|
|
103
|
+
default as `defaultTeam:`).
|
|
104
|
+
2. **In each `oats-local.yaml`**, when the deployment moves to 0.38.0: remove
|
|
105
|
+
`souls.teams` and `souls.default`, and, under (b), the local `teams` and
|
|
106
|
+
`defaultTeam` (`oats teams remove` works while local teams are closed).
|
|
107
|
+
3. **Check:** `oats teams` lists no problems, and `oats spawn <soul>
|
|
108
|
+
--preview` shows each soul's `defaultTeam` and `teams` as intended. Running
|
|
109
|
+
instances keep their spawn-time default; readiness reports
|
|
110
|
+
`default-team-changed` where it moved, and a respawn follows it.
|
|
111
|
+
|
|
112
|
+
What each kind of deployment changes:
|
|
113
|
+
|
|
114
|
+
- **A workspace whose people keep personal teams or a personal default** (for
|
|
115
|
+
example awebai/oats): `localTeams: true`, plus `souls:` for the souls'
|
|
116
|
+
shared teams (e.g. `"*": { teams: [oats] }`); drop `souls.teams` locally.
|
|
117
|
+
- **A workspace whose default team is a committed shared team**: commit
|
|
118
|
+
`defaultTeam: <label>` and the `souls:` entries; drop the local
|
|
119
|
+
`defaultTeam` (and any `souls.*` team keys).
|
|
120
|
+
- **A standalone deployment**: nothing changes for local teams and the local
|
|
121
|
+
default; drop `souls.teams` and `souls.default` (a standalone view has no
|
|
122
|
+
`souls:` patterns: every soul may join every local team).
|
|
123
|
+
|
|
124
|
+
Consumers: the Desktop's team views read `teamsApi: 1` / `soulTeamsApi: 1` and
|
|
125
|
+
edit through `oats soul teams --add`; they follow in their own change. Another
|
|
126
|
+
messaging provider receives `OATS_DEFAULT_TEAM_FROM=workspace` and `via` on
|
|
127
|
+
the `OATS_TEAMS` rows; one that records local teams with `oats teams add` needs
|
|
128
|
+
the workspace to allow them (oats.aweb 1.19.0 does both, above).
|
|
@@ -164,8 +164,8 @@ composed skills and instructions, a spawn records:
|
|
|
164
164
|
"key": "github.com/acme/agents", "commit": "3f2a9c1e…", "resolution": "20ec8ec527311d71d0973086",
|
|
165
165
|
"soul": { "repoKey": "github.com/acme/agents", "commit": "3f2a9c1e…" }
|
|
166
166
|
},
|
|
167
|
-
"teams": [{ "label": "ana-acme", "team": "ana-acme:ana.aweb.ai", "default": true, "from": "local" },
|
|
168
|
-
{ "label": "engineering", "team": "engineering:acme.aweb.ai", "default": false, "from": "shared" }],
|
|
167
|
+
"teams": [{ "label": "ana-acme", "team": "ana-acme:ana.aweb.ai", "default": true, "from": "local", "via": ["default", "local"] },
|
|
168
|
+
{ "label": "engineering", "team": "engineering:acme.aweb.ai", "default": false, "from": "shared", "via": ["workspace"] }],
|
|
169
169
|
"defaultTeam": { "label": "ana-acme", "team": "ana-acme:ana.aweb.ai", "from": "deployment" }
|
|
170
170
|
}
|
|
171
171
|
```
|
|
@@ -183,7 +183,8 @@ composed skills and instructions, a spawn records:
|
|
|
183
183
|
compares `workspace.soul` with the member's current commit too: `soul: <name>
|
|
184
184
|
from <member> @ <c7> [member moved since …]` (`--json`: `instances[].soul`).
|
|
185
185
|
- `teams` / `defaultTeam`: the soul's teams at spawn, exactly as the providers
|
|
186
|
-
received them (mapped teams only
|
|
186
|
+
received them (mapped teams only, each with `via`: why the soul may join it)
|
|
187
|
+
and its default: evidence, never rewritten.
|
|
187
188
|
A running home's hooks and messaging commands read the teams live
|
|
188
189
|
([capabilities.md](capabilities.md#teams-in-the-provider-environment)).
|
|
189
190
|
|
package/docs/workspaces.md
CHANGED
|
@@ -35,7 +35,7 @@ workspace commit. Schemas: [`oats-workspace.schema.json`](oats-workspace.schema.
|
|
|
35
35
|
[`oats-membership.schema.json`](oats-membership.schema.json),
|
|
36
36
|
[`soul.schema.json`](soul.schema.json), [`oats-local.schema.json`](oats-local.schema.json);
|
|
37
37
|
the lock's format is in [packages](packages.md#lock-v3). The JSON schemas encode
|
|
38
|
-
shapes; domain rules (
|
|
38
|
+
shapes; domain rules (labels that are shared teams, duplicate members, canonical `from:` keys,
|
|
39
39
|
the two `packages:` value forms) live in the kernel's `validateWorkspace` /
|
|
40
40
|
`validateSoul`, which are the authority.
|
|
41
41
|
|
|
@@ -54,13 +54,16 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
54
54
|
- git:github.com/acme/tools # a member that ALSO publishes a package (see below)
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
|
-
oats.framework: v1.
|
|
57
|
+
oats.framework: v1.5.0 # bare version → resolves through the official catalog
|
|
58
58
|
oats.okf: v4.1.1
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
|
61
61
|
teams: # SHARED teams: the same provider team for everyone
|
|
62
62
|
engineering: { description: Platform and release automation, team: "engineering:acme.aweb.ai" }
|
|
63
63
|
reviewers: { description: Code review } # declared, not created yet (no `team` id): readiness team-unmapped
|
|
64
|
+
defaultTeam: engineering # the workspace's default team; see Teams below
|
|
65
|
+
souls: # which teams each soul may join besides its default
|
|
66
|
+
platform/*: { teams: [reviewers] }
|
|
64
67
|
|
|
65
68
|
defaults:
|
|
66
69
|
capabilities:
|
|
@@ -146,7 +149,8 @@ its content digest.
|
|
|
146
149
|
### `oats-local.yaml`: the only per-machine file
|
|
147
150
|
|
|
148
151
|
Which workspace this machine realizes, where member clones live, host-owned
|
|
149
|
-
provider settings, this deployment's local teams and
|
|
152
|
+
provider settings, this deployment's local teams and default (where the
|
|
153
|
+
workspace allows them), host
|
|
150
154
|
facts for automations, and launch configurations. The full reference is
|
|
151
155
|
[configuration.md](configuration.md).
|
|
152
156
|
|
|
@@ -308,89 +312,130 @@ Harnesses start normally, with their own skill discovery intact
|
|
|
308
312
|
|
|
309
313
|
## Teams
|
|
310
314
|
|
|
311
|
-
A team is a messaging-provider team (for oats.aweb, an
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
315
|
+
A team is a messaging-provider team (for oats.aweb, an aweb team id
|
|
316
|
+
`<team>:<namespace>`) under a **label**. An instance that sits in two teams is
|
|
317
|
+
a bridge between them: one process reads both inboxes and could relay anything
|
|
318
|
+
from one to the other. So which teams an organisation's instances may be in is
|
|
319
|
+
the organisation's decision, committed in its workspace file and closed by
|
|
320
|
+
default; a deployment's `oats-local.yaml` adds a team only where the workspace
|
|
321
|
+
allows it. Souls stay team-free in their own repositories: an organisation
|
|
322
|
+
says how souls behave in its teams in its `oats-workspace.yaml`, and someone
|
|
323
|
+
running the same souls standalone sees none of it.
|
|
324
|
+
|
|
325
|
+
**What this protects against, and what it does not.** The workspace's team
|
|
326
|
+
rules prevent **accidental** joins in a cooperative installation, and they make
|
|
327
|
+
the organisation's intended team set visible and reviewable in its git. That is
|
|
328
|
+
all they claim. They do not stop an operator from bridging teams on purpose:
|
|
329
|
+
whoever holds credentials for two teams can read under one identity and relay
|
|
330
|
+
under the other, or copy content outside the messaging layer, and distinct keys
|
|
331
|
+
do not prove distinct processes. The messaging provider's admission (for aweb,
|
|
332
|
+
the team controller key signs memberships; hosted invites are mediated by the
|
|
333
|
+
service) controls who holds credentials for a team, not what a process does
|
|
334
|
+
with what it reads. A deployment's authority comes from the credentials it
|
|
335
|
+
holds, not from its path or its name, and a team certificate carries no soul,
|
|
336
|
+
workspace or home-team claim (such metadata would be self-asserted, not a
|
|
337
|
+
control).
|
|
338
|
+
|
|
339
|
+
### Where teams are declared
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
# oats-workspace.yaml (committed, edited by PR)
|
|
343
|
+
teams: # SHARED teams: the same provider team for everyone
|
|
344
|
+
engineering: { team: "engineering:acme.aweb.ai" }
|
|
345
|
+
security: { team: "security:acme.aweb.ai" }
|
|
346
|
+
docs: { description: Docs rota } # declared, not created yet: team-unmapped
|
|
347
|
+
defaultTeam: engineering # the workspace's fallback default team (a shared label)
|
|
348
|
+
localTeams: false # may deployments declare their own teams? (absent: false)
|
|
349
|
+
souls: # per soul pattern: its default team and the other teams it may join
|
|
350
|
+
"*": { teams: [] } # unlisted souls: default only (also what no entry means)
|
|
351
|
+
security-souls/*: { default: security, teams: [engineering] }
|
|
352
|
+
security-souls/incident-responder: { default: security, teams: [engineering, docs] }
|
|
353
|
+
oats.engineering/*: { teams: any } # any: every shared team of this file
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- **Shared teams** (`teams.<label> = { description?, team? }`): a shared team
|
|
357
|
+
without `team` is declared but not created yet (readiness `team-unmapped`):
|
|
358
|
+
its owner creates it with the messaging provider, then commits the id.
|
|
359
|
+
- **`souls:` keys are patterns**: `<member>/<soul>` or `<package>/<soul>`,
|
|
360
|
+
`<member>/*` or `<package>/*`, and `"*"`, with the member and package names
|
|
361
|
+
`souls.disabled` uses (a member's name is its repository's). A bare soul name
|
|
362
|
+
is refused. A key that is neither a pattern nor a soul the workspace offers
|
|
363
|
+
is the warning `team-soul-unknown` (a typo guard).
|
|
364
|
+
- **The most specific key wins outright** for a soul's teams, and lists never
|
|
365
|
+
merge: the soul's own key, then `<member|package>/*`, then `"*"`. The
|
|
366
|
+
default comes from the most specific key that sets one. So
|
|
367
|
+
`a/*: {default: security}` and `a/x: {teams: [docs]}` give `a/x` the default
|
|
368
|
+
`security` and the teams `[docs]` only.
|
|
369
|
+
- **A soul no key matches** (and no `"*"`) has its default only. That applies
|
|
370
|
+
to member and package souls alike: a workspace opens a package explicitly.
|
|
371
|
+
- **Every label** (`defaultTeam`, each `default`, each of `teams`) must be a
|
|
372
|
+
shared team of the same file; anything else is `E_WORKSPACE_SCHEMA` when the
|
|
373
|
+
file is read, never at a spawn on someone else's machine.
|
|
374
|
+
- **Local teams** (`oats-local.yaml` `teams.<label> = { team, description? }`)
|
|
375
|
+
and a local `defaultTeam` are allowed only when the workspace says
|
|
376
|
+
`localTeams: true`. Otherwise every spawn, preview and inspect is refused with
|
|
377
|
+
`E_WORKSPACE_SCHEMA` (reason `local-teams-closed`), naming both fixes: add
|
|
378
|
+
`localTeams: true` to the workspace file, or commit the teams and the
|
|
379
|
+
default there and remove them locally. A label in both files is
|
|
380
|
+
`team-label-collision` (a warning): the **shared** definition wins, and the
|
|
381
|
+
fix is renaming the local label.
|
|
382
|
+
- **The standalone view** (an explicit `standalone:`, or a fallback when the
|
|
383
|
+
workspace cannot be read) has no workspace rules: local teams and the local
|
|
384
|
+
`defaultTeam` apply there.
|
|
385
|
+
- `soul.yaml` and `oats-membership.yaml` say nothing about teams.
|
|
386
|
+
`oats-local.yaml` `souls.teams` and `souls.default` were removed in 0.38.0:
|
|
387
|
+
they are refused, and the refusal prints the `souls:` to commit instead.
|
|
388
|
+
|
|
389
|
+
### A soul's default team and its teams
|
|
390
|
+
|
|
391
|
+
The default, in order:
|
|
392
|
+
|
|
393
|
+
1. the soul's `default` from `souls:` (the most specific matching key that
|
|
394
|
+
sets one);
|
|
395
|
+
2. else the deployment's `oats-local.yaml` `defaultTeam`, only when
|
|
396
|
+
`localTeams: true`;
|
|
397
|
+
3. else the workspace's `defaultTeam`;
|
|
398
|
+
4. else none (`E_TEAM_UNCONFIGURED` when a messaging layer is active).
|
|
399
|
+
|
|
400
|
+
`defaultTeam.from` says which: `soul`, `deployment` or `workspace`.
|
|
401
|
+
|
|
402
|
+
The teams a soul may join: its default, plus the `teams` of its most specific
|
|
403
|
+
matching `souls:` key, plus, with `localTeams: true`, every local team the
|
|
404
|
+
deployment declares. Each row says why (`via`: `default`, `workspace`,
|
|
405
|
+
`local`). At spawn an instance joins its **default** only; the others are
|
|
337
406
|
eligible: offered, and joined on request through the provider (`join=` at
|
|
338
|
-
spawn, or its own verbs later)
|
|
339
|
-
|
|
340
|
-
and the soul only, the same for everyone. **A
|
|
341
|
-
changes trust or partitions the knowledge
|
|
407
|
+
spawn, or its own verbs later), which refuses a team that is not eligible.
|
|
408
|
+
A local default no file declares is `E_TEAM_UNKNOWN`. Capabilities compose
|
|
409
|
+
from the workspace defaults and the soul only, the same for everyone. **A
|
|
410
|
+
label never gates, restricts, changes trust or partitions the knowledge
|
|
411
|
+
store.**
|
|
412
|
+
|
|
413
|
+
### The verbs
|
|
342
414
|
|
|
343
|
-
|
|
415
|
+
They never call a provider. `oats teams add` and `oats teams default` edit
|
|
416
|
+
`oats-local.yaml` in place, where the workspace allows local teams; `oats teams
|
|
417
|
+
remove` runs anywhere (removing local teams is how a deployment moves them into
|
|
418
|
+
the workspace). A soul's teams are edited by a PR to `oats-workspace.yaml`:
|
|
419
|
+
`oats soul teams` only reads them.
|
|
344
420
|
|
|
345
421
|
```
|
|
346
|
-
oats teams [--json] #
|
|
347
|
-
oats teams add <label> --team <id> [--description d] # declare a local team (the first one becomes the default)
|
|
348
|
-
oats teams remove <label> # refused while
|
|
349
|
-
oats teams default <label>
|
|
350
|
-
oats soul teams <soul>|'*' [--
|
|
422
|
+
oats teams [--json] # the teams, ids, the default, localTeams, the workspace's souls:, problems
|
|
423
|
+
oats teams add <label> --team <id> [--description d] # declare a local team (the first one becomes the local default)
|
|
424
|
+
oats teams remove <label> # refused while it is the local default (E_TEAM_IN_USE) or shared (E_TEAM_SHARED)
|
|
425
|
+
oats teams default <label> # the local default
|
|
426
|
+
oats soul teams <soul>|'*' [--json] # a soul's default and teams here, and which souls: key gave them
|
|
351
427
|
```
|
|
352
428
|
|
|
353
|
-
The messaging provider's own setup creates provider teams
|
|
354
|
-
|
|
429
|
+
The messaging provider's own setup creates provider teams (see the provider's
|
|
430
|
+
documentation). The spawn preview, `inspect` and `oats souls` report a soul's
|
|
355
431
|
`teams` and `defaultTeam`; readiness reports the team problems in
|
|
356
432
|
`checks.configured` (`E_TEAM_UNCONFIGURED` when a messaging layer is active and
|
|
357
433
|
there is no default; `team-unmapped`, blocking when it is the default;
|
|
358
|
-
`default-team-changed` for a running
|
|
359
|
-
its environment — see
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
OATS 0.38.0 commits a soul's teams in the workspace (team model 3,
|
|
365
|
-
awebai/oats#484): the teams an organisation's instances may join become its
|
|
366
|
-
own decision, visible and reviewable in its git, so a deployment's
|
|
367
|
-
`oats-local.yaml` no longer adds one by accident. 0.36.x and 0.37.x prepare for it, so
|
|
368
|
-
every workspace and deployment can migrate first:
|
|
369
|
-
|
|
370
|
-
- **`oats-workspace.yaml` accepts the new keys** and validates them, but
|
|
371
|
-
**does not apply them**: a soul's teams and default are still resolved as
|
|
372
|
-
above, from `oats-local.yaml`.
|
|
373
|
-
|
|
374
|
-
```yaml
|
|
375
|
-
defaultTeam: engineering # the workspace's fallback default team
|
|
376
|
-
localTeams: true # deployments may declare their own teams (absent: false)
|
|
377
|
-
souls: # per pattern: "*", <member|package>/*, <member|package>/<soul>
|
|
378
|
-
"*": { teams: [] } # default only ({} says the same)
|
|
379
|
-
security-souls/*: { default: security, teams: [engineering] }
|
|
380
|
-
oats.engineering/*: { teams: any } # every shared team
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
`<member|package>` is the name `souls.disabled` uses. Every label (`defaultTeam`,
|
|
384
|
-
a `souls:` `default`, each of its `teams`) must be a shared team in `teams:` of
|
|
385
|
-
the same file; anything else is `E_WORKSPACE_SCHEMA` when the file is read. A
|
|
386
|
-
key naming a member or package the workspace does not have is not an error.
|
|
387
|
-
- **The readiness warning `team-model-3-migration`** (never blocking) names
|
|
388
|
-
what 0.38.0 will refuse: `souls.teams` / `souls.default` in `oats-local.yaml`
|
|
389
|
-
(they move to `souls:`), and local `teams` / `defaultTeam` while the workspace
|
|
390
|
-
does not say `localTeams: true` (fix: add `localTeams: true`, or commit the
|
|
391
|
-
teams and `defaultTeam` in the workspace file). `oats teams`, readiness (and
|
|
392
|
-
so the Desktop) and `oats doctor` show it. The migration steps are in the
|
|
393
|
-
[0.36.1 release notes](release-notes/v0.36.1.md).
|
|
434
|
+
local-teams-closed; `team-soul-unknown`; `default-team-changed` for a running
|
|
435
|
+
instance). The provider receives them in its environment — see
|
|
436
|
+
[capabilities.md](capabilities.md#teams-in-the-provider-environment). Exact
|
|
437
|
+
shapes: [desktop-cli-api.md](desktop-cli-api.md#team-model-3-feature-team-model-3-oats-0370-replaces-feature-team-model-2).
|
|
438
|
+
Why it is shaped this way: [team model 3](design/2026-10-02-team-model-3.md).
|
|
394
439
|
|
|
395
440
|
## Provider payloads have three homes
|
|
396
441
|
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir
|
|
|
18
18
|
import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility } from "./resolve.mjs";
|
|
21
|
-
import { recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
|
|
21
|
+
import { discoveredTeamKeys, isRemovedTeamKeys, isTeamRefusal, recordedTeams, reportRows, soulKeyOf, teamKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
|
|
22
22
|
import { launchLayers, launchReport } from "./launch-preference.mjs";
|
|
23
23
|
import { loadLocal } from "./workspace.mjs";
|
|
24
24
|
import { ensureModuleTree } from "./operator-dispatch.mjs";
|
|
@@ -35,10 +35,10 @@ const obj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
|
35
35
|
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
36
36
|
|
|
37
37
|
/** A resolution refusal that is a readiness FACT (the soul cannot resolve as the
|
|
38
|
-
* lock stands), not a failure to answer: reported as a failing `installed` item
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
38
|
+
* lock stands), not a failure to answer: reported as a failing `installed` item —
|
|
39
|
+
* or, for a refusal of the soul's teams (isTeamRefusal), as a team item in `configured`. */
|
|
40
|
+
const RESOLUTION_FACTS = new Set(["E_PACKAGE_MISSING", "E_PACKAGE_INTEGRITY", "E_CAPABILITY_MISSING", "E_LOCK_SCHEMA", "E_REQUIREMENT_INACTIVE"]);
|
|
41
|
+
const isResolutionFact = (e) => RESOLUTION_FACTS.has(e?.code) || isTeamRefusal(e);
|
|
42
42
|
|
|
43
43
|
/** Is `name` an executable on PATH? (manifest `requires`, no shell). */
|
|
44
44
|
function onPath(name) {
|
|
@@ -89,6 +89,9 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
89
89
|
const modules = obj(meta.modules) ? meta.modules : {};
|
|
90
90
|
const agentsRoot = dirname(dirname(dirname(realHome)));
|
|
91
91
|
const deployment = dirname(agentsRoot);
|
|
92
|
+
// The deployment's removed team keys (team model 3) refuse every reader of its oats-local.yaml, before
|
|
93
|
+
// anything below falls back past an unreadable local file (live teams, launch, session defaults).
|
|
94
|
+
try { loadLocal(deployment); } catch (e) { if (isRemovedTeamKeys(e)) throw e; }
|
|
92
95
|
const ws = obj(meta.workspace) ? meta.workspace : {};
|
|
93
96
|
const soulDir = instanceSoulDir(realHome, meta) ?? null;
|
|
94
97
|
const { definition, problems } = readSoulYaml(soulDir);
|
|
@@ -111,13 +114,13 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
111
114
|
if (external === null && (discovery.members || []).some((m) => m.key === ws.soul?.repoKey)) external = false;
|
|
112
115
|
}
|
|
113
116
|
const slots = Object.fromEntries(LAYERS.map((l) => [l, Object.keys(modules).find((n) => manifests[n]?.layer === l) ?? null]));
|
|
114
|
-
// The home's soul key
|
|
115
|
-
const teamKey = ws.soul?.package
|
|
117
|
+
// The home's soul key in the workspace's souls: (teamKeyOf): <package>/<soul>, or <member>/<soul>.
|
|
118
|
+
const teamKey = ws.soul?.package ? teamKeyOf({ ...ws.soul, package: ws.soul.package.id }) : teamKeyOf({ name: meta.agent, repoKey: ws.soul?.repoKey ?? "" });
|
|
116
119
|
let live, model = null;
|
|
117
120
|
if (discovery) {
|
|
118
121
|
model = teamModel(discovery.standalone === true ? null : discovery.workspace, local, { workspaceKey: discovery.key ?? null });
|
|
119
122
|
try { const t = soulTeams(model, teamKey); live = { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" }; }
|
|
120
|
-
catch (e) { if (!
|
|
123
|
+
catch (e) { if (!isTeamRefusal(e)) throw e; live = { ...recordedTeams(meta), source: "recorded" }; }
|
|
121
124
|
} else if (readLive) live = await liveTeams(realHome, meta, { remoteOptions });
|
|
122
125
|
else live = { ...recordedTeams(meta), source: "recorded" };
|
|
123
126
|
// Feature launch-preference: the recorded launch (frozen), and what --reselect-launch would choose now —
|
|
@@ -147,7 +150,7 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
147
150
|
let prepared = null, resolutionError = null;
|
|
148
151
|
try { prepared = await prepareInstance(contextDir, soul, { remoteOptions }); }
|
|
149
152
|
catch (e) {
|
|
150
|
-
if (!
|
|
153
|
+
if (!isResolutionFact(e)) throw e;
|
|
151
154
|
resolutionError = { code: e.code, message: e.message, details: e.details ?? null };
|
|
152
155
|
}
|
|
153
156
|
const found = loadLocal(contextDir);
|
|
@@ -160,11 +163,11 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
160
163
|
}
|
|
161
164
|
const res = prepared?.resolution;
|
|
162
165
|
// A refused resolution still names the soul's teams when they resolve (a package refusal, …); a
|
|
163
|
-
// refusal of the teams themselves (
|
|
166
|
+
// refusal of the teams themselves (isTeamRefusal) names none.
|
|
164
167
|
const model = teamModel(discovery?.standalone === true ? null : discovery?.workspace ?? null, prepared?.local ?? found.local, { workspaceKey: discovery?.key ?? null });
|
|
165
|
-
const teamKey =
|
|
168
|
+
const teamKey = teamKeyOf(soulEntry);
|
|
166
169
|
let teams = res?.teams ?? null, defaultTeam = res ? res.defaultTeam ?? null : null;
|
|
167
|
-
if (!res) { try { const t = soulTeams(model, teamKey); teams = reportRows(t.teams); defaultTeam = t.defaultTeam; } catch (e) { if (!
|
|
170
|
+
if (!res) { try { const t = soulTeams(model, teamKey); teams = reportRows(t.teams); defaultTeam = t.defaultTeam; } catch (e) { if (!isTeamRefusal(e)) throw e; } }
|
|
168
171
|
// The soul being asked about is materialised as a spawn would (ensureWorkspaceSoul: idempotent per
|
|
169
172
|
// commit, never touching a directory an instance links), so its checks always get OATS_SOUL; only a
|
|
170
173
|
// spawn wrote the per-commit copy before, and a provider asked about a soul not yet spawned at this
|
|
@@ -181,7 +184,7 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
181
184
|
// Feature launch-preference: what a spawn with no flags would launch here.
|
|
182
185
|
let launchConfigs = {};
|
|
183
186
|
try { launchConfigs = launchConfigsAt(deployment); } catch { launchConfigs = {}; }
|
|
184
|
-
const launch = launchReportFor({ layers: prepared?.launch ?? launchLayers({ definition: soulEntry.definition, entry: soulEntry, key:
|
|
187
|
+
const launch = launchReportFor({ layers: prepared?.launch ?? launchLayers({ definition: soulEntry.definition, entry: soulEntry, key: soulKeyOf(soulEntry), local: found.local }), launchConfigs, contextDir: deployment });
|
|
185
188
|
return {
|
|
186
189
|
kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch, session: newSessionDefaults(deployment),
|
|
187
190
|
subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
|
|
@@ -281,7 +284,7 @@ export function inspectDocument(t, { kernel }) {
|
|
|
281
284
|
* `required: false`. A home also compares its spawn-time default with the live one. */
|
|
282
285
|
function teamItems(t) {
|
|
283
286
|
if (!t.teamModel) return [];
|
|
284
|
-
const problems = teamProblems(t.teamModel, { key: t.teamKey, messaging: !!t.slots?.messaging });
|
|
287
|
+
const problems = teamProblems(t.teamModel, { key: t.teamKey, messaging: !!t.slots?.messaging, soulKeys: t.discovery ? discoveredTeamKeys(t.discovery) : null });
|
|
285
288
|
const same = (a, b) => (a?.label ?? null) === (b?.label ?? null) && (a?.team ?? null) === (b?.team ?? null);
|
|
286
289
|
if (t.home && t.teamsSource === "live" && t.meta?.defaultTeam !== undefined && !same(t.recordedDefaultTeam, t.defaultTeam)) {
|
|
287
290
|
problems.push({ code: "default-team-changed", severity: "warning", recorded: t.recordedDefaultTeam, current: t.defaultTeam,
|
|
@@ -408,7 +411,7 @@ export const PROVIDER_CHECK_BUDGET_MS = 60_000;
|
|
|
408
411
|
export async function readinessDocument(t, { selector = null, remoteOptions, catalog = null, budgetMs = PROVIDER_CHECK_BUDGET_MS } = {}) {
|
|
409
412
|
const installed = [], configured = [], member = [], providers = [];
|
|
410
413
|
const cap = (id) => ({ capability: { id } });
|
|
411
|
-
if (t.resolutionError && !
|
|
414
|
+
if (t.resolutionError && !isTeamRefusal(t.resolutionError)) {
|
|
412
415
|
installed.push(item(t.soul.name, "fail", { producer: "workspace resolution", code: t.resolutionError.code, reason: t.resolutionError.message, evidence: t.resolutionError.details, remedy: "oats sync (the lock must provide every package capability the soul resolves)" }));
|
|
413
416
|
}
|
|
414
417
|
if (t.soul.copyError) {
|
|
@@ -21,7 +21,7 @@ import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from
|
|
|
21
21
|
import { oatsError } from "./errors.mjs";
|
|
22
22
|
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
|
|
23
23
|
import { resolveSoul, kernelCompatibility } from "./resolve.mjs";
|
|
24
|
-
import { BY_TEAM_REMOVED, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
|
|
24
|
+
import { BY_TEAM_REMOVED, isRemovedTeamKeys, isTeamRefusal, memberNameOf, recordedTeams, reportRows, soulKeyOf, soulTeams, teamKeyOf, teamModel } from "./teams.mjs";
|
|
25
25
|
import { launchLayers } from "./launch-preference.mjs";
|
|
26
26
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
27
27
|
import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
@@ -159,6 +159,8 @@ export function disabledEntry(local, soulEntry) {
|
|
|
159
159
|
* host; a standalone deployment has local teams only and reads none.
|
|
160
160
|
* → { teams, defaultTeam, source: "live" } — or, when that cannot answer (the host is unreachable, the
|
|
161
161
|
* deployment or its teams are unreadable), the spawn-time record: { teams, defaultTeam, source: "recorded", reason }.
|
|
162
|
+
* An oats-local.yaml still carrying the removed team keys (isRemovedTeamKeys) is thrown, never answered
|
|
163
|
+
* from the record: the deployment must migrate before its homes act on teams again.
|
|
162
164
|
* `source` travels as OATS_TEAMS_SOURCE: a provider leaves a joined team only on a `live` answer.
|
|
163
165
|
*/
|
|
164
166
|
export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
@@ -166,7 +168,7 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
166
168
|
const soul = meta?.workspace?.soul;
|
|
167
169
|
// A home without a workspace soul record has nothing to resolve.
|
|
168
170
|
if (!soul || typeof meta.agent !== "string") return recorded("no-workspace-soul");
|
|
169
|
-
const key = soul.package
|
|
171
|
+
const key = soul.package ? teamKeyOf({ ...soul, package: soul.package.id }) : teamKeyOf({ name: meta.agent, repoKey: soul.repoKey ?? "" });
|
|
170
172
|
const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
|
|
171
173
|
try {
|
|
172
174
|
const found = loadLocal(deployment);
|
|
@@ -175,7 +177,10 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
175
177
|
const wsObs = standalone ? null : await observeWorkspace(found.local.workspace, { remoteOptions, remote });
|
|
176
178
|
const t = soulTeams(teamModel(wsObs?.workspace ?? null, found.local, { workspaceKey: wsObs?.key ?? null }), key);
|
|
177
179
|
return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" };
|
|
178
|
-
} catch (e) {
|
|
180
|
+
} catch (e) {
|
|
181
|
+
if (isRemovedTeamKeys(e)) throw e;
|
|
182
|
+
return recorded(isTeamRefusal(e) ? "invalid-teams" : "unreadable", { code: e?.code || "E_REMOTE_UNREADABLE", message: e?.message ?? String(e) });
|
|
183
|
+
}
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
/** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
|
|
@@ -314,9 +319,7 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
314
319
|
/** The taught member name of a repo key: the last path segment without `.git`
|
|
315
320
|
* (`github.com/northwind/platform` → `platform`). The same rule `oats onboard`
|
|
316
321
|
* and `oats sync` print (memberLabel). */
|
|
317
|
-
export
|
|
318
|
-
return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
|
|
319
|
-
}
|
|
322
|
+
export { memberNameOf };
|
|
320
323
|
|
|
321
324
|
/** The convention path of a member's clone: `<deployment>/<member name>` — except a
|
|
322
325
|
* member called `agents`, which is cloned as `agents-repo/` because `<deployment>/agents/`
|