@awebai/oats 0.29.4 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +12 -6
  2. package/bin/oats.mjs +194 -50
  3. package/capabilities/oats-aweb/bin/oats-aweb.mjs +538 -204
  4. package/capabilities/oats-aweb/injects/aweb.md +1 -1
  5. package/capabilities/oats-aweb/lib/binding-wire.mjs +31 -22
  6. package/capabilities/oats-aweb/oats.json +5 -12
  7. package/capabilities/oats-aweb/skills/VENDORED.md +4 -4
  8. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +1 -1
  9. package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +83 -13
  10. package/capabilities/oats-code-review/injects/reviewer.md +26 -0
  11. package/capabilities/oats-code-review/oats.json +16 -0
  12. package/capabilities/oats-code-review/skills/adversarial-review/SKILL.md +66 -0
  13. package/capabilities/oats-code-review/skills/review-dev-docs/SKILL.md +30 -0
  14. package/capabilities/oats-code-review/skills/security-review/SKILL.md +56 -0
  15. package/capabilities/oats-code-review/skills/simplification-review/SKILL.md +34 -0
  16. package/capabilities/oats-developer/injects/developer.md +38 -0
  17. package/capabilities/oats-developer/oats.json +17 -0
  18. package/capabilities/oats-developer/skills/execution-strategy/SKILL.md +43 -0
  19. package/capabilities/oats-developer/skills/maintain-dev-docs/SKILL.md +47 -0
  20. package/capabilities/oats-developer/skills/run-the-review-loop/SKILL.md +65 -0
  21. package/capabilities/oats-developer/skills/understand-the-spec/SKILL.md +37 -0
  22. package/capabilities/oats-developer/skills/worktrees/SKILL.md +36 -0
  23. package/capabilities/oats-engineering-expert/injects/expert.md +37 -0
  24. package/capabilities/oats-engineering-expert/oats.json +17 -0
  25. package/capabilities/oats-engineering-expert/skills/coordinate-developers/SKILL.md +37 -0
  26. package/capabilities/oats-engineering-expert/skills/coordinate-experts/SKILL.md +52 -0
  27. package/capabilities/oats-engineering-expert/skills/land-your-prs/SKILL.md +50 -0
  28. package/capabilities/oats-engineering-expert/skills/plan-and-spec/SKILL.md +53 -0
  29. package/capabilities/oats-engineering-expert/skills/verify-developer-work/SKILL.md +49 -0
  30. package/capabilities/oats-okf/bin/oats-okf.mjs +8 -4
  31. package/capabilities/oats-okf/lib/binding-wire.mjs +47 -15
  32. package/capabilities/oats-okf/lib/inspection.mjs +26 -7
  33. package/capabilities/oats-okf/lib/sources.mjs +16 -2
  34. package/capabilities/oats-okf/lib/worker.mjs +5 -16
  35. package/capabilities/oats-okf/oats.json +6 -3
  36. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +2 -2
  37. package/capabilities/oats-okf-harvest/oats.json +3 -3
  38. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +6 -6
  39. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +2 -2
  40. package/capabilities/oats-okf-maintenance/injects/maintainer.md +1 -1
  41. package/capabilities/oats-okf-maintenance/oats.json +2 -2
  42. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +1 -1
  43. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +11 -23
  44. package/capabilities/oats-workspace-experts/injects/oats-experts.md +26 -0
  45. package/capabilities/oats-workspace-experts/oats.json +9 -0
  46. package/docs/capabilities.md +160 -171
  47. package/docs/capability-manifest.schema.json +6 -11
  48. package/docs/configuration.md +213 -64
  49. package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
  50. package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
  51. package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
  52. package/docs/design/2026-09-27-team-model-v2.md +97 -117
  53. package/docs/design/2026-09-28-automations-trust.md +38 -0
  54. package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
  55. package/docs/design/HISTORY.md +65 -0
  56. package/docs/design/README.md +23 -54
  57. package/docs/desktop-cli-api.md +1787 -1777
  58. package/docs/desktop.md +30 -91
  59. package/docs/execution-targets.md +146 -292
  60. package/docs/first-team.md +31 -17
  61. package/docs/implementation.md +76 -288
  62. package/docs/integrations.md +118 -320
  63. package/docs/knowledge-capability-authoring.md +25 -52
  64. package/docs/knowledge-reference/acceptance.md +3 -3
  65. package/docs/knowledge-reference/adoption.md +1 -1
  66. package/docs/knowledge-reference/harvester.md +2 -2
  67. package/docs/knowledge-reference/package-craft.md +3 -3
  68. package/docs/knowledge-reference/provider-mapping.md +3 -6
  69. package/docs/knowledge-reference/reader-capture.md +3 -3
  70. package/docs/knowledge-theory.md +62 -166
  71. package/docs/knowledge.md +225 -404
  72. package/docs/layers.md +42 -97
  73. package/docs/oats-local.schema.json +58 -5
  74. package/docs/oats-membership.schema.json +1 -8
  75. package/docs/oats-package.schema.json +5 -5
  76. package/docs/oats-workspace.schema.json +8 -22
  77. package/docs/official-catalog.md +25 -28
  78. package/docs/packages.md +45 -63
  79. package/docs/plans/0.30-close-out.md +61 -0
  80. package/docs/release-lane.md +77 -0
  81. package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
  82. package/docs/release-notes/v0.19.0.md +48 -147
  83. package/docs/release-notes/v0.19.1.md +2 -3
  84. package/docs/release-notes/v0.19.3.md +2 -15
  85. package/docs/release-notes/v0.20.0.md +0 -15
  86. package/docs/release-notes/v0.22.0.md +71 -138
  87. package/docs/release-notes/v0.22.1.md +42 -90
  88. package/docs/release-notes/v0.22.10.md +1 -1
  89. package/docs/release-notes/v0.22.11.md +1 -47
  90. package/docs/release-notes/v0.22.12.md +4 -13
  91. package/docs/release-notes/v0.22.13.md +1 -42
  92. package/docs/release-notes/v0.22.14.md +3 -11
  93. package/docs/release-notes/v0.22.15.md +1 -46
  94. package/docs/release-notes/v0.22.16.md +6 -8
  95. package/docs/release-notes/v0.22.18.md +1 -99
  96. package/docs/release-notes/v0.22.19.md +3 -14
  97. package/docs/release-notes/v0.22.2.md +6 -15
  98. package/docs/release-notes/v0.22.3.md +0 -1
  99. package/docs/release-notes/v0.22.4.md +1 -14
  100. package/docs/release-notes/v0.22.5.md +2 -12
  101. package/docs/release-notes/v0.22.6.md +0 -3
  102. package/docs/release-notes/v0.23.0.md +9 -25
  103. package/docs/release-notes/v0.23.1.md +9 -25
  104. package/docs/release-notes/v0.23.2.md +2 -4
  105. package/docs/release-notes/v0.24.0.md +56 -97
  106. package/docs/release-notes/v0.24.1.md +7 -11
  107. package/docs/release-notes/v0.24.10.md +34 -45
  108. package/docs/release-notes/v0.24.11.md +12 -20
  109. package/docs/release-notes/v0.24.12.md +35 -48
  110. package/docs/release-notes/v0.24.13.md +34 -41
  111. package/docs/release-notes/v0.24.2.md +9 -13
  112. package/docs/release-notes/v0.24.3.md +7 -11
  113. package/docs/release-notes/v0.24.4.md +6 -6
  114. package/docs/release-notes/v0.24.5.md +6 -10
  115. package/docs/release-notes/v0.24.6.md +2 -5
  116. package/docs/release-notes/v0.24.7.md +46 -75
  117. package/docs/release-notes/v0.24.8.md +58 -96
  118. package/docs/release-notes/v0.24.9.md +38 -54
  119. package/docs/release-notes/v0.25.0.md +59 -76
  120. package/docs/release-notes/v0.25.1.md +57 -81
  121. package/docs/release-notes/v0.25.2.md +51 -70
  122. package/docs/release-notes/v0.25.3.md +11 -13
  123. package/docs/release-notes/v0.25.4.md +9 -13
  124. package/docs/release-notes/v0.25.5.md +3 -5
  125. package/docs/release-notes/v0.25.6.md +20 -29
  126. package/docs/release-notes/v0.25.7.md +5 -7
  127. package/docs/release-notes/v0.25.8.md +26 -39
  128. package/docs/release-notes/v0.26.0.md +175 -646
  129. package/docs/release-notes/v0.27.0.md +4 -5
  130. package/docs/release-notes/v0.27.1.md +4 -6
  131. package/docs/release-notes/v0.27.2.md +1 -1
  132. package/docs/release-notes/v0.28.0.md +57 -124
  133. package/docs/release-notes/v0.29.0.md +89 -208
  134. package/docs/release-notes/v0.29.1.md +1 -1
  135. package/docs/release-notes/v0.29.2.md +3 -4
  136. package/docs/release-notes/v0.30.0.md +205 -0
  137. package/docs/schedules.md +280 -363
  138. package/docs/servers.md +99 -117
  139. package/docs/soul.schema.json +2 -9
  140. package/docs/souls-and-instances.md +145 -158
  141. package/docs/workspaces.md +132 -215
  142. package/lib/automations.mjs +21 -6
  143. package/lib/core.mjs +226 -74
  144. package/lib/instance-events.mjs +1 -1
  145. package/lib/instance-inspect.mjs +109 -34
  146. package/lib/instance-lifecycle.mjs +14 -1
  147. package/lib/instance-resolution.mjs +26 -27
  148. package/lib/launch-preference.mjs +87 -0
  149. package/lib/materialize.mjs +3 -3
  150. package/lib/resolve.mjs +29 -87
  151. package/lib/schedule.mjs +1 -1
  152. package/lib/teams-verbs.mjs +195 -0
  153. package/lib/teams.mjs +190 -0
  154. package/lib/triggers.mjs +2 -2
  155. package/lib/workspace.mjs +54 -147
  156. package/package-catalog.json +9 -15
  157. package/package.json +1 -1
  158. package/skills/oats-getting-started/SKILL.md +25 -13
  159. package/capabilities/oats-review/injects/review.md +0 -69
  160. package/capabilities/oats-review/oats.json +0 -10
  161. package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
  162. package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
  163. package/docs/conventions.md +0 -90
  164. package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
  165. package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
  166. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
  167. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
  168. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
  169. package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
  170. package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
  171. package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
  172. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
  173. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
  174. package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
  175. package/docs/design/2026-09-15-captured-dispatch.md +0 -127
  176. package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
  177. package/docs/design/2026-09-15-package-preparation.md +0 -100
  178. package/docs/design/2026-09-15-portable-data-contract.md +0 -121
  179. package/docs/design/2026-09-15-portable-declarations.md +0 -189
  180. package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
  181. package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
  182. package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
  183. package/docs/design/2026-09-15-source-observation.md +0 -119
  184. package/docs/design/2026-09-16-captured-admission.md +0 -77
  185. package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
  186. package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
  187. package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
  188. package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
  189. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
  190. package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
  191. package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
  192. package/docs/design/2026-09-16-portable-onboarding.md +0 -179
  193. package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
  194. package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
  195. package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
  196. package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
  197. package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
  198. package/docs/design/2026-09-17-captured-native-start.md +0 -58
  199. package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
  200. package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
  201. package/docs/design/2026-09-17-public-captured-start.md +0 -108
  202. package/docs/design/2026-09-17-public-prepare-request.md +0 -90
  203. package/docs/design/2026-09-18-captured-pi-host.md +0 -205
  204. package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
  205. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
  206. package/docs/design/2026-09-20-redesign-program-board.md +0 -142
  207. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
  208. package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
  209. package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
  210. package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
  211. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
  212. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
  213. package/docs/design/2026-09-24-phase-d-plan.md +0 -305
  214. package/docs/design/2026-09-25-teams-contract.md +0 -258
  215. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
  216. package/docs/design/desktop-ux-plan.md +0 -362
  217. package/docs/design/launch-configurations.md +0 -168
  218. package/docs/design/okf-mirror-provenance.md +0 -105
  219. package/docs/design/operations-contract.md +0 -141
  220. package/docs/oats-member.schema.json +0 -38
  221. package/skills/integration-authoring/SKILL.md +0 -84
  222. package/skills/oats-support/SKILL.md +0 -79
  223. package/skills/skill-craft/SKILL.md +0 -109
  224. package/skills/soul-craft/SKILL.md +0 -116
package/lib/resolve.mjs CHANGED
@@ -25,13 +25,12 @@
25
25
  *
26
26
  * Composition order (soul wins; `off` removes):
27
27
  * workspace.defaults.{knowledge,messaging,tasks} (slot defaults; a soul `none` drops them)
28
- * ⊕ workspace.defaults.capabilities ⊕ workspace.defaults.byTeam[<label>].capabilities for each of the
29
- * soul's team labels, in order ⊕ soul.capabilities. Two labels that give one capability different
30
- * entries → E_TEAM_CONFLICT naming both (teams contract 2026-09-25, decision 2).
28
+ * ⊕ workspace.defaults.capabilities ⊕ soul.capabilities. (Team labels compose nothing: 0.30 removed
29
+ * defaults.byTeam, so a soul's composition is the same for every person and machine.)
31
30
  *
32
31
  * Slot `none` (contract §3, post-0.25.0 rule): a soul's `<slot>: none` EMPTIES the slot — it drops the
33
32
  * workspace's `defaults.<slot>` AND any capability of that layer the workspace defaults contributed
34
- * (`defaults.capabilities`, `defaults.byTeam[team]`). A layer-bearing capability the SOUL ITSELF declares
33
+ * (`defaults.capabilities`). A layer-bearing capability the SOUL ITSELF declares
35
34
  * next to `none` is contradictory and stays E_SLOT_CONFLICT { reason: "none" } (spell `<cap>: off` to
36
35
  * remove a default explicitly; drop the soul's own line to fill the slot).
37
36
  *
@@ -45,7 +44,7 @@
45
44
  * everything, while a preview can say WHAT changed since the previous instance (declarations | payload | both).
46
45
  *
47
46
  * Payloads (decision 14), later wins on scalars/arrays, objects deep-merge:
48
- * workspace.messaging (messaging slot only; its base, `byTeam` stripped and never merged — teams amendment K)
47
+ * workspace.messaging (messaging slot only)
49
48
  * ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
50
49
  *
51
50
  * This module shells out to nothing. Remote access is injected (`remote`, default
@@ -58,6 +57,7 @@ import { readFileSync } from "node:fs";
58
57
  import { posix } from "node:path";
59
58
  import { oatsError as baseOatsError } from "./errors.mjs";
60
59
  import * as defaultRemote from "./remote.mjs";
60
+ import { BY_TEAM_REMOVED, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
61
61
  import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
62
62
  import { settingValueProblems } from "./capability-contract.mjs";
63
63
 
@@ -150,14 +150,13 @@ export function payloadOrigins(layers) {
150
150
  return origins;
151
151
  }
152
152
 
153
- /** `byTeam` is RESERVED (decision 23): it addresses a per-team payload and is legal only at the top level of
154
- * workspace.messaging, where the resolver merges base ⊕ byTeam[soul.team] and strips it. In any other payload
155
- * layer — a soul's slot payload, local.settings[cap], spawn.providers[cap] — it would reach the provider
156
- * verbatim; refused at the layer's top level with E_WORKSPACE_SCHEMA reason "reserved-key" and the path named. */
157
- const RESERVED_KEY = "byTeam";
153
+ /** `byTeam` was removed in 0.30 (team model v2): in any payload layer — the workspace's messaging, a soul's
154
+ * slot payload, local.settings[cap], spawn.providers[cap] — it would reach the provider verbatim as a dead
155
+ * key; refused at the layer's top level with E_WORKSPACE_SCHEMA reason "removed-key" and the path named. */
156
+ const REMOVED_KEY = "byTeam";
158
157
  function assertNoReservedKey(value, path) {
159
- if (isObject(value) && Object.hasOwn(value, RESERVED_KEY)) {
160
- throw fail("E_WORKSPACE_SCHEMA", `${path}/${RESERVED_KEY}: ${show(RESERVED_KEY)} is reserved — it is legal only at the top level of the workspace file's messaging: payload (decision 23)`, { path: `${path}/${RESERVED_KEY}`, key: RESERVED_KEY, reason: "reserved-key" });
158
+ if (isObject(value) && Object.hasOwn(value, REMOVED_KEY)) {
159
+ throw fail("E_WORKSPACE_SCHEMA", `${path}/${REMOVED_KEY}: ${BY_TEAM_REMOVED}`, { path: `${path}/${REMOVED_KEY}`, key: REMOVED_KEY, reason: "removed-key" });
161
160
  }
162
161
  }
163
162
 
@@ -333,46 +332,16 @@ const choiceOf = (value, path, via) => {
333
332
  throw fail("E_WORKSPACE_SCHEMA", `${path} must be { from: <location> } or "off", got ${show(value)}`, { path, value });
334
333
  };
335
334
 
336
- /** A soul's team labels, primary first: `team` is a label or a list of distinct labels (teams
337
- * contract 2026-09-25, decision 1). A discovery SoulEntry carries them as `labels`; an entry
338
- * without them (a hand-built one) has its `team` as the only label. */
339
- export function teamLabelsOf(soulEntry) {
340
- if (Array.isArray(soulEntry?.labels)) return soulEntry.labels.filter((l) => typeof l === "string");
341
- return typeof soulEntry?.team === "string" ? [soulEntry.team] : [];
342
- }
343
-
344
- /**
345
- * The eligible teams of a soul (teams contract, decision 3): one entry per label, in soul order,
346
- * `{ label, team, mapped, payload }`. `payload` is workspace.messaging's base ⊕ byTeam[label] when the
347
- * workspace maps the label, else the base alone with `mapped: false`; `team` is the payload's team id
348
- * when mapped (null otherwise). Kernel-owned and delivered BESIDE a provider's settings, never inside
349
- * them; joining any of them is the messaging provider's explicit act. No label → [] (the workspace's default team only).
350
- * Pure: resolveSoul validates every carried label's entry (reserved byTeam, hostOnly keys) first.
351
- */
352
- export function teamsOf(workspace, labels) {
353
- const messaging = isObject(workspace?.messaging) ? workspace.messaging : {};
354
- const { byTeam, ...base } = messaging;
355
- return (labels || []).map((label) => {
356
- const mapped = isObject(byTeam) && Object.hasOwn(byTeam, label) && isObject(byTeam[label]);
357
- const payload = mapped ? mergePayload(base, byTeam[label]) : mergePayload(base);
358
- return { label, team: mapped && typeof payload.team === "string" ? payload.team : null, mapped, payload };
359
- });
360
- }
361
-
362
335
  /**
363
336
  * The ordered capability map of a soul BEFORE any lookup:
364
- * slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ defaults.byTeam[label] for
365
- * each label in order ⊕ soul.capabilities
337
+ * slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ soul.capabilities
366
338
  * → [{ name, from, via }] sorted by name; `off` removes the entry from every lower layer.
367
- * `via` is one of "defaults.<slot>" | "defaults.capabilities" | "defaults.byTeam.<label>" | "soul".
368
- * Two labels that give one capability different entries (`off` vs a location, or two locations) →
369
- * E_TEAM_CONFLICT { capability, labels: [a, b] }; identical entries are not a conflict, and a capability
370
- * the soul names itself is not one either (the soul's entry wins over both).
339
+ * `via` is one of "defaults.<slot>" | "defaults.capabilities" | "soul".
371
340
  * `offs` (optional, feature desktop-facts): receives what the soul turned off — { name, reason: "off",
372
341
  * overrides } for each capability its own `off` removed from a lower layer, and { name, reason: "slot-none",
373
342
  * slot, overrides: "workspace" } for a slot default its `<slot>: none` dropped (`overrides`: fromOfVia vocabulary).
374
343
  */
375
- export function composeCapabilities(workspace, soulDefinition, { team = null, labels = team === null ? [] : [team], offs = null } = {}) {
344
+ export function composeCapabilities(workspace, soulDefinition, { offs = null } = {}) {
376
345
  const map = new Map();
377
346
  const apply = (entries, via, path) => {
378
347
  for (const [name, value] of Object.entries(entries || {})) {
@@ -392,22 +361,6 @@ export function composeCapabilities(workspace, soulDefinition, { team = null, la
392
361
  apply(d, `defaults.${slot}`, `/defaults/${slot}`);
393
362
  }
394
363
  apply(defaults.capabilities, "defaults.capabilities", "/defaults/capabilities");
395
- const byLabel = new Map(); // capability -> { label, choice } of the first label that set it
396
- // The soul's own entry wins over every label, so a capability the soul names settles the conflict.
397
- const soulNames = new Set(Object.keys(isObject(soulDefinition?.capabilities) ? soulDefinition.capabilities : {}));
398
- for (const label of labels) {
399
- if (!isObject(defaults.byTeam) || !Object.hasOwn(defaults.byTeam, label) || !isObject(defaults.byTeam[label])) continue;
400
- const entries = defaults.byTeam[label].capabilities, path = `/defaults/byTeam/${label}/capabilities`;
401
- for (const [name, value] of Object.entries(entries || {})) {
402
- const choice = choiceOf(value, `${path}/${name}`, `defaults.byTeam.${label}`);
403
- const seen = byLabel.get(name);
404
- if (seen && !soulNames.has(name) && canonicalJson(seen.choice) !== canonicalJson(choice)) {
405
- throw fail("E_TEAM_CONFLICT", `${name}: team labels ${show(seen.label)} and ${show(label)} give it different entries (${canonicalJson(seen.choice)} vs ${canonicalJson(choice)}) in defaults.byTeam — make them agree, or name ${name} in the soul`, { capability: name, labels: [seen.label, label], entries: [seen.choice, choice], paths: [`/defaults/byTeam/${seen.label}/capabilities/${name}`, `${path}/${name}`] });
406
- }
407
- if (!seen) byLabel.set(name, { label, choice });
408
- }
409
- apply(entries, `defaults.byTeam.${label}`, path);
410
- }
411
364
  apply(soulDefinition?.capabilities, "soul", "/capabilities");
412
365
  return [...map.values()].sort((a, b) => byCodepoint(a.name, b.name));
413
366
  }
@@ -599,17 +552,18 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
599
552
  const workspace = isObject(discovery?.workspace) ? discovery.workspace : null;
600
553
  assertSoulDiscovered(discovery, soulEntry);
601
554
  const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
602
- const labels = teamLabelsOf(soulEntry);
603
- const team = labels[0] ?? null; // the PRIMARY label: the merged messaging payload and OATS_TEAM_LABEL follow it
604
- const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, team, path: soulEntry.path ?? null };
555
+ const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, path: soulEntry.path ?? null };
556
+ // Team model v2: the soul's teams HERE (the committed shared teams + oats-local.yaml). An unknown
557
+ // label (E_TEAM_UNKNOWN) or an ineligible souls.default (E_TEAM_NOT_ELIGIBLE) refuses the soul.
558
+ const soulTeamsHere = soulTeams(teamModel(discovery?.standalone === true ? null : workspace, local, { workspaceKey: discovery?.key ?? null }), soulKeyOf(soulEntry));
605
559
 
606
560
  // Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
607
561
  // What the soul turned off (feature desktop-facts): its own `off` over a lower layer, and a `<slot>: none`
608
562
  // that dropped a workspace default below. Provenance, like slotsFrom.
609
563
  const offs = [];
610
564
  const declared = discovery?.standalone === true
611
- ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { labels, offs })
612
- : composeCapabilities(workspace, definition, { labels, offs });
565
+ ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { offs })
566
+ : composeCapabilities(workspace, definition, { offs });
613
567
  const turnedOff = offs;
614
568
 
615
569
  const modules = [];
@@ -727,21 +681,11 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
727
681
  // provider receives one merged payload without provenance and cannot enforce this itself.
728
682
  const hostOnly = hostOnlyKeys(m.manifest);
729
683
  const committed = (payload, path, origin) => { assertNoHostOnlyKey(payload, path, hostOnly, m.name); add(payload, origin); };
684
+ // The provider's settings are base ⊕ soul ⊕ host ⊕ spawn. Teams are not settings: they reach the
685
+ // provider beside them (OATS_DEFAULT_TEAM*, OATS_TEAMS).
730
686
  if (m.layer === "messaging" && isObject(workspace?.messaging)) {
731
- // Decision 23 as amended by K (teams contract, co-lead ruling): the provider's settings are
732
- // base ⊕ soul ⊕ host ⊕ spawn. No byTeam[<label>] is merged, the primary's included: each
733
- // label's base ⊕ byTeam[label] is delivered only in its `teams` entry (teamsOf → OATS_TEAMS),
734
- // so settings.team is the default team a host, soul or spawn set. `byTeam` never reaches the
735
- // provider, and the primary team's own payload may still not nest one.
736
- const { byTeam, ...base } = workspace.messaging;
737
- committed(base, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
738
- // Every label's entry still reaches the provider, in OATS_TEAMS (teamsOf), so each one the soul
739
- // carries — the primary's and every other — may nest no byTeam and carry no hostOnly key.
740
- for (const label of labels) {
741
- if (!isObject(byTeam) || !Object.hasOwn(byTeam, label) || !isObject(byTeam[label])) continue;
742
- assertNoReservedKey(byTeam[label], `/messaging/byTeam/${label}`);
743
- assertNoHostOnlyKey(byTeam[label], `/messaging/byTeam/${label}`, hostOnly, m.name);
744
- }
687
+ assertNoReservedKey(workspace.messaging, "/messaging");
688
+ committed(workspace.messaging, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
745
689
  }
746
690
  if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); committed(definition[m.layer], `/${m.layer}`, { kind: "soul", at: `soul.yaml#/${m.layer}` }); }
747
691
  if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); add(settings[m.name], { kind: "host", at: `oats-local.yaml#/settings/${m.name}` }); } // the host layer: hostOnly keys are legal here
@@ -805,23 +749,21 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
805
749
  const payloadRevision = revisionOf(payloads);
806
750
  const revision = revisionOf({ declRevision, payloadRevision });
807
751
  // payloadOrigins is provenance only: it is derived from the same inputs the two revisions already
808
- // bind, so it does not enter either fingerprint. `teams` (the eligible teams, teams contract
809
- // decision 3) is live messaging state, not composition: it stays out of both fingerprints too, so a
810
- // single-label soul's revision is what it was.
752
+ // bind, so it does not enter either fingerprint. `teams` / `defaultTeam` (team model v2) are
753
+ // deployment-local messaging state, not composition: they stay out of both fingerprints too.
811
754
  // `slotsFrom` (where each filled slot's capability came from) is provenance, like payloadOrigins: outside
812
755
  // both fingerprints, so the same capability reached another way is not a composition change. So are
813
756
  // `capabilitiesFrom` (the same per composed capability) and `turnedOff` (feature desktop-facts).
814
- const teams = teamsOf(discovery?.standalone === true ? null : workspace, labels);
757
+ const teams = reportRows(soulTeamsHere.teams), defaultTeam = soulTeamsHere.defaultTeam;
815
758
  const capabilitiesFrom = Object.fromEntries(modules.map((m) => [m.name, fromOfVia(viaOf.get(m.name))]));
816
759
  turnedOff.sort((a, b) => byCodepoint(a.name, b.name));
817
- return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, slotsFrom, capabilitiesFrom, turnedOff, declRevision, payloadRevision, revision });
760
+ return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, defaultTeam, slotsFrom, capabilitiesFrom, turnedOff, declRevision, payloadRevision, revision });
818
761
  }
819
762
 
820
763
  /** Where a composed capability came from, as the Desktop names it (`layers.<layer>.from`): the soul's own
821
- * entry → "soul"; defaults.<slot> or defaults.capabilities → "workspace"; defaults.byTeam.<label> → "team:<label>". */
764
+ * entry → "soul"; defaults.<slot> or defaults.capabilities → "workspace". */
822
765
  export function fromOfVia(via) {
823
766
  if (via === "soul") return "soul";
824
- if (typeof via === "string" && via.startsWith("defaults.byTeam.")) return `team:${via.slice("defaults.byTeam.".length)}`;
825
767
  if (typeof via === "string" && via.startsWith("defaults.")) return "workspace";
826
768
  return null;
827
769
  }
package/lib/schedule.mjs CHANGED
@@ -507,7 +507,7 @@ export function childEnv() {
507
507
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
508
508
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
509
509
  "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
510
- "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE", "OATS_TRIGGER_EVENT_FILE",
510
+ "OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_TEAMS", "OATS_TEAMS_SOURCE", "OATS_TRIGGER_EVENT_FILE",
511
511
  ]) delete env[key];
512
512
  return env;
513
513
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The team model v2 verbs (docs/desktop-cli-api.md, `oats teams`, `oats soul teams`): CONFIG ONLY — they
3
+ * never call a provider. Each reads the deployment's oats-local.yaml and the committed shared teams, and a
4
+ * mutation rewrites oats-local.yaml in place (comments and every other key kept), after validating the
5
+ * result: the local schema, and no label reference the write would leave unknown or ineligible.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import YAML from "yaml";
9
+ import { parseConfigData } from "./config-data.mjs";
10
+ import { oatsError } from "./errors.mjs";
11
+ import { validateLocal } from "./workspace.mjs";
12
+ import { LOCAL_FILE, TEAM_ID_RE, pointerKey, soulTeams, teamModel, teamProblems, teamReferences } from "./teams.mjs";
13
+ import { writeFileAtomic } from "./packages.mjs";
14
+
15
+ export const TEAMS_API = 1, SOUL_TEAMS_API = 1;
16
+ const LABEL_RE = /^[a-z0-9][a-z0-9._-]*$/;
17
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
18
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
19
+ function fail(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
20
+
21
+ /** Whether a messaging layer is active in this deployment: the workspace fills the messaging slot. */
22
+ const messagingActive = (workspace) => isObject(workspace?.defaults?.messaging);
23
+
24
+ /** `oats teams --json`. `ctx`: { deployment, local, workspace (the committed file, or null), workspaceKey }. */
25
+ export function teamsDocument(ctx) {
26
+ const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
27
+ const teams = [...model.labels.values()].sort((a, b) => byCodepoint(a.label, b.label))
28
+ .map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label === model.defaultTeam, at: d.at }));
29
+ return {
30
+ teamsApi: TEAMS_API, deployment: ctx.deployment, defaultTeam: model.defaultTeam, teams,
31
+ souls: { teams: structuredClone(model.souls.teams), default: structuredClone(model.souls.default) },
32
+ problems: teamProblems(model, { messaging: messagingActive(ctx.workspace) }),
33
+ };
34
+ }
35
+
36
+ /** `oats soul teams <soul>|'*' --json`: `key` is the soul's key ("*" for every soul), `soul` its name. */
37
+ export function soulTeamsDocument(ctx, { soul, key }) {
38
+ const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
39
+ const t = soulTeams(model, key);
40
+ const list = (v) => (Array.isArray(v) ? [...v] : []);
41
+ return {
42
+ soulTeamsApi: SOUL_TEAMS_API, soul, key, defaultTeam: t.defaultTeam, teams: t.teams,
43
+ local: { teams: list(model.souls.teams[key]), default: key === "*" ? null : model.souls.default[key] ?? null },
44
+ all: list(model.souls.teams["*"]),
45
+ };
46
+ }
47
+
48
+ /* ───────────────────────────── writing oats-local.yaml ─────────────────── */
49
+
50
+ const clone = (v) => (v === undefined ? undefined : structuredClone(v));
51
+ /** The four team keys of a local value, as plain data. */
52
+ const teamState = (local) => ({
53
+ teams: clone(isObject(local?.teams) ? local.teams : {}),
54
+ defaultTeam: typeof local?.defaultTeam === "string" ? local.defaultTeam : null,
55
+ soulsTeams: clone(isObject(local?.souls?.teams) ? local.souls.teams : {}),
56
+ soulsDefault: clone(isObject(local?.souls?.default) ? local.souls.default : {}),
57
+ });
58
+ const modelOf = (ctx, local) => teamModel(ctx.workspace, local, { workspaceKey: ctx.workspaceKey });
59
+ /** The refusals a state carries (unknown / ineligible references), keyed so a write can tell new ones. */
60
+ const refusals = (ctx, local) => new Map(teamProblems(modelOf(ctx, local))
61
+ .filter((p) => p.code === "E_TEAM_UNKNOWN" || p.code === "E_TEAM_NOT_ELIGIBLE").map((p) => [`${p.code} ${p.at}`, p]));
62
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
63
+ const readLocal = (ctx) => {
64
+ const text = readFileSync(ctx.localPath, "utf8");
65
+ const doc = YAML.parseDocument(text, { keepSourceTokens: true });
66
+ if (doc.errors?.length) throw fail("E_WORKSPACE_SCHEMA", `${ctx.localPath}: ${doc.errors[0].message}`, { path: ctx.localPath });
67
+ return { text, doc, local: parseConfigData(text, { origin: { kind: "local", path: ctx.localPath } }).value };
68
+ };
69
+
70
+ /**
71
+ * Edit `doc` from `before` to `next` SURGICALLY: only the entries that change are touched, so comments and
72
+ * styles on everything else (inline comments on sibling entries, flow sequences) are kept. A map or list
73
+ * left empty is removed, and so is an empty `souls`.
74
+ */
75
+ function applyState(doc, before, next) {
76
+ const map = (path, from, to, entry) => {
77
+ for (const k of Object.keys(from)) if (!Object.hasOwn(to, k)) doc.deleteIn([...path, k]);
78
+ for (const [k, v] of Object.entries(to)) if (!same(from[k], v)) entry([...path, k], from[k], v);
79
+ const node = doc.getIn(path);
80
+ if (YAML.isMap(node) && node.items.length === 0) doc.deleteIn(path);
81
+ };
82
+ // A team definition, key by key (its own comments stay on untouched keys).
83
+ map(["teams"], before.teams, next.teams, (path, was, now) => {
84
+ if (!isObject(was) || !YAML.isMap(doc.getIn(path))) { doc.setIn(path, now); return; }
85
+ for (const k of Object.keys(was)) if (!Object.hasOwn(now, k)) doc.deleteIn([...path, k]);
86
+ for (const [k, v] of Object.entries(now)) if (!same(was[k], v)) doc.setIn([...path, k], v);
87
+ });
88
+ if (next.defaultTeam !== before.defaultTeam) { if (next.defaultTeam === null) doc.deleteIn(["defaultTeam"]); else doc.setIn(["defaultTeam"], next.defaultTeam); }
89
+ // A soul's list, item by item (its style — flow or block — and the other items' comments stay).
90
+ map(["souls", "teams"], before.soulsTeams, next.soulsTeams, (path, was, now) => {
91
+ const seq = doc.getIn(path, true);
92
+ if (!Array.isArray(was) || !YAML.isSeq(seq)) { doc.setIn(path, now); return; }
93
+ seq.items = seq.items.filter((item) => now.includes(YAML.isScalar(item) ? item.value : item));
94
+ for (const label of now) if (!was.includes(label)) seq.items.push(doc.createNode(label));
95
+ });
96
+ map(["souls", "default"], before.soulsDefault, next.soulsDefault, (path, _was, now) => doc.setIn(path, now));
97
+ const souls = doc.get("souls");
98
+ if (YAML.isMap(souls) && souls.items.length === 0) doc.delete("souls");
99
+ }
100
+
101
+ /**
102
+ * Rewrite oats-local.yaml. `mutate(state, model)` edits the plain `state` (teamState) and may throw a
103
+ * refusal; both come from the file AS IT IS NOW (re-read here, never the command's startup snapshot), so
104
+ * two verbs running together cannot drop each other's change: the write is compare-and-swap — when the
105
+ * file changed after this read, the edit is redone on the new content (a few times, then E_LOCAL_CHANGED).
106
+ * `check(local)` may refuse the result before anything is written. → { changed, local } (the value now).
107
+ */
108
+ function editLocal(ctx, mutate, { check } = {}) {
109
+ for (let attempt = 0; attempt < 5; attempt++) {
110
+ const { text, doc, local: current } = readLocal(ctx);
111
+ const before = teamState(current);
112
+ const next = teamState(current);
113
+ mutate(next, modelOf(ctx, current));
114
+ if (same(next, before)) return { changed: false, local: current };
115
+ applyState(doc, before, next);
116
+ const out = doc.toString();
117
+ const local = parseConfigData(out, { origin: { kind: "local", path: ctx.localPath } }).value;
118
+ const problems = validateLocal(local);
119
+ if (problems.length) throw fail("E_WORKSPACE_SCHEMA", `the rewritten oats-local.yaml would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`, { problems });
120
+ const had = refusals(ctx, current);
121
+ for (const [k, p] of refusals(ctx, local)) if (!had.has(k)) throw fail(p.code, `${p.message}; nothing was written`, Object.fromEntries(Object.entries(p).filter(([key]) => !["code", "severity", "message", "fix"].includes(key))));
122
+ if (check) check(local);
123
+ if (readFileSync(ctx.localPath, "utf8") !== text) continue; // changed under us: redo on the new content
124
+ writeFileAtomic(ctx.localPath, out);
125
+ return { changed: true, local };
126
+ }
127
+ throw fail("E_LOCAL_CHANGED", `${ctx.localPath} kept changing while this edit ran; nothing was written — run it again`, { path: ctx.localPath });
128
+ }
129
+
130
+ const assertLabel = (label) => {
131
+ if (typeof label !== "string" || !LABEL_RE.test(label)) throw fail("E_BAD_ARGS", `${JSON.stringify(label)} is not a team label (lowercase letters, digits, and . _ - after the first)`, { label });
132
+ };
133
+
134
+ /** `oats teams add <label> --team <id> [--description <d>]` → { changed, local }. */
135
+ export function teamsAdd(ctx, label, { team, description } = {}) {
136
+ assertLabel(label);
137
+ if (typeof team !== "string" || !team.trim()) throw fail("E_BAD_ARGS", "oats teams add needs --team <provider team id>", { label });
138
+ if (!TEAM_ID_RE.test(team.trim())) throw fail("E_BAD_ARGS", `${JSON.stringify(team)} is not a team id: it must match ${TEAM_ID_RE.source} (a letter or digit first; no whitespace or control characters; at most 256 characters)`, { label, team });
139
+ return editLocal(ctx, (s, model) => {
140
+ if (model.labels.has(label)) {
141
+ const from = model.labels.get(label).from;
142
+ throw fail("E_TEAM_EXISTS", `team ${label} is already declared (${from === "shared" ? "shared, in oats-workspace.yaml" : "local, in oats-local.yaml"})${from === "local" ? ": remove it first (`oats teams remove`) to redefine it" : ""}`, { label, from });
143
+ }
144
+ s.teams[label] = { team: team.trim(), ...(typeof description === "string" && description ? { description } : {}) };
145
+ // The first team added becomes the deployment's default.
146
+ if (s.defaultTeam === null) s.defaultTeam = label;
147
+ });
148
+ }
149
+
150
+ /** `oats teams remove <label>`: a LOCAL team nothing references (no cascade). */
151
+ export function teamsRemove(ctx, label) {
152
+ return editLocal(ctx, (s, model) => {
153
+ if (!model.local.has(label)) {
154
+ if (model.shared.has(label)) throw fail("E_TEAM_SHARED", `team ${label} is shared (declared in oats-workspace.yaml): it is edited by a PR to that file, never removed here`, { label, at: model.shared.get(label).at });
155
+ throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared`, { label });
156
+ }
157
+ // A label also declared in the committed file still resolves after the local copy goes (the
158
+ // collision's fix): its references stay valid, so only a purely local label must be unreferenced.
159
+ const usedBy = model.shared.has(label) ? [] : teamReferences(model, label);
160
+ if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}): remove the references first (\`oats teams default\`, \`oats soul teams … --remove ${label}\`)`, { label, usedBy });
161
+ delete s.teams[label];
162
+ });
163
+ }
164
+
165
+ /** `oats teams default <label>`: a label of either file. */
166
+ export function teamsDefault(ctx, label) {
167
+ return editLocal(ctx, (s, model) => {
168
+ if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
169
+ s.defaultTeam = label;
170
+ });
171
+ }
172
+
173
+ /**
174
+ * `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]`.
175
+ * `key` is the soul's key ("*" for every soul). → { changed, local }.
176
+ */
177
+ export function soulTeamsEdit(ctx, key, { add = [], remove = [], setDefault = null, clearDefault = false } = {}) {
178
+ if (key === "*" && (setDefault !== null || clearDefault)) throw fail("E_BAD_ARGS", "a default is per soul: --default / --clear-default need a soul, not '*' (the deployment's default is `oats teams default`)", { soul: key });
179
+ if (setDefault !== null && clearDefault) throw fail("E_BAD_ARGS", "choose --default <label> or --clear-default, not both");
180
+ // --default must name one of the soul's teams after the write.
181
+ const check = setDefault === null ? undefined : (local) => {
182
+ try { soulTeams(modelOf(ctx, local), key); }
183
+ catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE") throw fail("E_TEAM_NOT_ELIGIBLE", `${e.message}; nothing was written`, { soul: key, label: setDefault, at: `${LOCAL_FILE}#/souls/default/${pointerKey(key)}` }); throw e; }
184
+ };
185
+ return editLocal(ctx, (s, model) => {
186
+ for (const label of [...add, ...remove, ...(setDefault !== null ? [setDefault] : [])]) {
187
+ if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
188
+ }
189
+ const list = Array.isArray(s.soulsTeams[key]) ? s.soulsTeams[key] : [];
190
+ const next = [...list.filter((l) => !remove.includes(l)), ...add.filter((l) => !list.includes(l) && !remove.includes(l))].filter((l, i, a) => a.indexOf(l) === i);
191
+ if (next.length) s.soulsTeams[key] = next; else delete s.soulsTeams[key];
192
+ if (clearDefault) delete s.soulsDefault[key];
193
+ if (setDefault !== null) s.soulsDefault[key] = setDefault;
194
+ }, { check });
195
+ }
package/lib/teams.mjs ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Team model v2 (docs/design/2026-09-27-team-model-v2.md, option B), pure.
3
+ *
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.
11
+ *
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.
16
+ *
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
+ import { oatsError } from "./errors.mjs";
22
+
23
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
24
+ const str = (v) => (typeof v === "string" && v ? v : null);
25
+ const labelsOf = (v) => (Array.isArray(v) ? v.filter((l) => typeof l === "string") : []);
26
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
27
+ export const pointerKey = (k) => String(k).replace(/~/g, "~0").replace(/\//g, "~1");
28
+ function fail(code, message, details) {
29
+ const e = oatsError(code, message, details);
30
+ e.details = details;
31
+ return e;
32
+ }
33
+
34
+ export const LOCAL_FILE = "oats-local.yaml";
35
+ /** A provider team id, as both schemas' `teams.*.team` pattern: the kernel's SAFETY rule only (never
36
+ * `-`-led, so never an option; no whitespace or control characters; bounded). The messaging provider
37
+ * validates its own id shape. Keep equal to docs/oats-{local,workspace}.schema.json. */
38
+ export const TEAM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,255}$/;
39
+ /** What 0.30 removed, named by the schema problems that replace them. */
40
+ export const TEAM_MEMBERSHIP_MOVED = "team membership is local since 0.30: `oats soul teams`";
41
+ 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)";
42
+
43
+ const UNMAPPED_FIX = "its owner runs `oats aweb setup`, then commits the id";
44
+
45
+ /** The deployment's teams from the committed workspace and oats-local.yaml (either may be null). */
46
+ export function teamModel(workspace, local, { workspaceKey = null } = {}) {
47
+ const shared = new Map(), localTeams = new Map();
48
+ for (const [label, def] of Object.entries(isObject(workspace?.teams) ? workspace.teams : {})) {
49
+ shared.set(label, { label, team: str(def?.team), description: str(def?.description), from: "shared",
50
+ at: `${workspaceKey ? `${workspaceKey}:` : ""}oats-workspace.yaml#/teams/${pointerKey(label)}` });
51
+ }
52
+ for (const [label, def] of Object.entries(isObject(local?.teams) ? local.teams : {})) {
53
+ localTeams.set(label, { label, team: str(def?.team), description: str(def?.description), from: "local", at: `${LOCAL_FILE}#/teams/${pointerKey(label)}` });
54
+ }
55
+ const souls = isObject(local?.souls) ? local.souls : {};
56
+ return {
57
+ shared, local: localTeams,
58
+ labels: new Map([...localTeams, ...shared]), // the committed definition wins a collision
59
+ defaultTeam: str(local?.defaultTeam),
60
+ souls: { teams: isObject(souls.teams) ? souls.teams : {}, default: isObject(souls.default) ? souls.default : {} },
61
+ };
62
+ }
63
+
64
+ /** The key a soul has in souls.teams / souls.default. */
65
+ export function soulKeyOf(soulEntry) {
66
+ return typeof soulEntry?.package === "string" && typeof soulEntry.qualifiedName === "string" ? soulEntry.qualifiedName : soulEntry.name;
67
+ }
68
+
69
+ 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 });
70
+
71
+ /**
72
+ * One soul's teams here (key "*": the deployment default + souls.teams["*"]).
73
+ * → { key, defaultTeam: DefaultTeam | null, teams: [TeamRow + via] } | throws E_TEAM_UNKNOWN / E_TEAM_NOT_ELIGIBLE.
74
+ * `via` ⊂ ["default", "*", "soul"], in that order: why the soul has the team.
75
+ */
76
+ export function soulTeams(model, key) {
77
+ const known = (label, at) => { if (!model.labels.has(label)) throw unknown(label, at); };
78
+ if (model.defaultTeam !== null) known(model.defaultTeam, `${LOCAL_FILE}#/defaultTeam`);
79
+ const star = labelsOf(model.souls.teams["*"]);
80
+ star.forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/*/${i}`));
81
+ const own = key === "*" ? [] : labelsOf(model.souls.teams[key]);
82
+ own.forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/${pointerKey(key)}/${i}`));
83
+ const override = key === "*" ? null : str(model.souls.default[key]);
84
+ if (override !== null) {
85
+ const at = `${LOCAL_FILE}#/souls/default/${pointerKey(key)}`;
86
+ known(override, at);
87
+ if (override !== model.defaultTeam && !star.includes(override) && !own.includes(override)) {
88
+ 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 });
89
+ }
90
+ }
91
+ const defaultLabel = override ?? model.defaultTeam;
92
+ const via = new Map();
93
+ const add = (label, why) => { const v = via.get(label) ?? []; if (!v.includes(why)) v.push(why); via.set(label, v); };
94
+ if (defaultLabel !== null) add(defaultLabel, "default");
95
+ for (const l of star) add(l, "*");
96
+ for (const l of own) add(l, "soul");
97
+ const teams = [...via].map(([label, v]) => {
98
+ const d = model.labels.get(label);
99
+ return { label, team: d.team, default: label === defaultLabel, from: d.from, via: v };
100
+ }).sort((a, b) => (b.default - a.default) || byCodepoint(a.label, b.label));
101
+ const defaultTeam = defaultLabel === null ? null : { label: defaultLabel, team: model.labels.get(defaultLabel).team, from: override !== null ? "soul" : "deployment" };
102
+ return { key, defaultTeam, teams };
103
+ }
104
+
105
+ /** TeamRows as the reports carry them (unmapped included, no `via`). */
106
+ export const reportRows = (teams) => (teams || []).map(({ label, team, default: d, from }) => ({ label, team, default: d, from }));
107
+ /** TeamRows as OATS_TEAMS and instance.json carry them: mapped only. */
108
+ export const envRows = (teams) => reportRows(teams).filter((r) => r.team !== null);
109
+
110
+ /** Every reference to `label` in oats-local.yaml, in written order (`oats teams remove` refuses on any). */
111
+ export function teamReferences(model, label) {
112
+ const refs = [];
113
+ if (model.defaultTeam === label) refs.push("defaultTeam");
114
+ for (const [key, list] of Object.entries(model.souls.teams)) if (labelsOf(list).includes(label)) refs.push(`souls.teams:${key}`);
115
+ for (const [key, l] of Object.entries(model.souls.default)) if (l === label) refs.push(`souls.default:${key}`);
116
+ return refs;
117
+ }
118
+
119
+ const collisionProblem = (label, shared, local) => ({
120
+ code: "team-label-collision", label, severity: "warning",
121
+ shared: { team: shared.team, description: shared.description, at: shared.at },
122
+ local: { team: local.team, description: local.description, at: local.at },
123
+ message: `team ${label} is declared in both oats-workspace.yaml (shared) and oats-local.yaml (local); the shared definition wins`,
124
+ fix: "rename the local label in oats-local.yaml",
125
+ });
126
+ const unmappedProblem = (d, isDefault) => isDefault
127
+ ? { 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\`` }
128
+ : { 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 };
129
+ 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>`" });
130
+ const refusalProblem = (e) => ({ code: e.code, ...e.details, severity: "failure", message: e.message,
131
+ 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`)" });
132
+
133
+ /**
134
+ * Readiness problems. Without `key`: the deployment's (`oats teams`): collisions, unmapped shared
135
+ * teams (a failure when it is `defaultTeam`), every unknown reference, every ineligible
136
+ * souls.default. With `key`: only what concerns that soul (`default` marks ITS default).
137
+ * `messaging`: a messaging layer is active, so no default is E_TEAM_UNCONFIGURED.
138
+ */
139
+ export function teamProblems(model, { key = null, messaging = false } = {}) {
140
+ const problems = [];
141
+ if (key !== null) {
142
+ let t;
143
+ try { t = soulTeams(model, key); }
144
+ catch (e) { if (e.code === "E_TEAM_UNKNOWN" || e.code === "E_TEAM_NOT_ELIGIBLE") return [refusalProblem(e)]; throw e; }
145
+ 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)));
146
+ for (const r of t.teams) if (r.team === null) problems.push(unmappedProblem(model.labels.get(r.label), r.default));
147
+ if (messaging && t.defaultTeam === null) problems.push(unconfiguredProblem());
148
+ return problems;
149
+ }
150
+ const labels = [...model.labels.keys()].sort(byCodepoint);
151
+ 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)));
152
+ for (const label of labels) { const d = model.labels.get(label); if (d.team === null) problems.push(unmappedProblem(d, label === model.defaultTeam)); }
153
+ const known = (label, at) => { if (!model.labels.has(label)) problems.push(refusalProblem(unknown(label, at))); };
154
+ if (model.defaultTeam !== null) known(model.defaultTeam, `${LOCAL_FILE}#/defaultTeam`);
155
+ for (const [k, list] of Object.entries(model.souls.teams)) labelsOf(list).forEach((l, i) => known(l, `${LOCAL_FILE}#/souls/teams/${pointerKey(k)}/${i}`));
156
+ for (const [k, l] of Object.entries(model.souls.default)) if (typeof l === "string") known(l, `${LOCAL_FILE}#/souls/default/${pointerKey(k)}`);
157
+ for (const [k, l] of Object.entries(model.souls.default)) {
158
+ if (typeof l !== "string" || !model.labels.has(l)) continue;
159
+ 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; }
160
+ }
161
+ if (messaging && model.defaultTeam === null) problems.push(unconfiguredProblem());
162
+ return problems;
163
+ }
164
+
165
+ /**
166
+ * The provider environment (docs/desktop-cli-api.md, The provider environment) from a soul's teams
167
+ * `{ defaultTeam, teams, source: "live"|"recorded" }`, or null when they are not known. A name that
168
+ * does not apply is `undefined`, so a child process never inherits an ambient value. The pre-0.30
169
+ * names are always unset.
170
+ */
171
+ export function teamsEnv(t) {
172
+ const known = isObject(t) && Array.isArray(t.teams);
173
+ const d = known && isObject(t.defaultTeam) ? t.defaultTeam : null;
174
+ return {
175
+ OATS_DEFAULT_TEAM: d?.label ?? undefined,
176
+ OATS_DEFAULT_TEAM_ID: d?.team ?? undefined,
177
+ OATS_DEFAULT_TEAM_FROM: d?.from ?? undefined,
178
+ OATS_TEAMS: known ? JSON.stringify(envRows(t.teams)) : undefined,
179
+ OATS_TEAMS_SOURCE: known && (t.source === "live" || t.source === "recorded") ? t.source : undefined,
180
+ OATS_TEAM_LABEL: undefined, OATS_TEAM_LABELS: undefined, OATS_TEAM_ID: undefined,
181
+ };
182
+ }
183
+
184
+ /** A home's spawn-time teams (instance.json `teams` / `defaultTeam`) → { teams, defaultTeam }; teams null
185
+ * when the home recorded none in this shape (spawned before 0.30: its teams are unknown, never guessed). */
186
+ export function recordedTeams(meta) {
187
+ const rows = Array.isArray(meta?.teams) && meta.teams.every((r) => isObject(r) && typeof r.label === "string" && typeof r.default === "boolean") ? meta.teams : null;
188
+ const d = isObject(meta?.defaultTeam) && typeof meta.defaultTeam.label === "string" ? meta.defaultTeam : null;
189
+ return { teams: rows, defaultTeam: rows ? d : null };
190
+ }
package/lib/triggers.mjs CHANGED
@@ -483,8 +483,8 @@ export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = n
483
483
  for (const e of entries) {
484
484
  const id = e.id;
485
485
  // A workspace trigger another host runs is not this tick's business; one NAMED for this host
486
- // that it cannot run (another gh account) is reported every tick until someone fixes it.
487
- if (e.placement.reason) { if (e.placement.reason === "owner-mismatch" && e.placement.enabledHere) rec(id, "not-here", { reason: e.placement.reason, detail: e.placement.detail }); continue; }
486
+ // that it cannot run (another gh account, or not trusted here) is reported every tick until someone fixes it.
487
+ if (e.placement.reason) { if (["owner-mismatch", "untrusted"].includes(e.placement.reason) && e.placement.enabledHere) rec(id, "not-here", { reason: e.placement.reason, detail: e.placement.detail }); continue; }
488
488
  if (e.invalid) { if (e.placement.enabledHere) rec(id, "invalid", { error: e.invalid.message }); continue; }
489
489
  if (!e.placement.runsHere) continue;
490
490
  const def = runDef(e);