@awebai/oats 0.29.3 → 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 (232) hide show
  1. package/README.md +12 -6
  2. package/bin/oats.mjs +203 -54
  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 +16 -9
  31. package/capabilities/oats-okf/lib/binding-wire.mjs +47 -15
  32. package/capabilities/oats-okf/lib/config.mjs +2 -1
  33. package/capabilities/oats-okf/lib/consult.mjs +26 -4
  34. package/capabilities/oats-okf/lib/harvest-switch.mjs +16 -3
  35. package/capabilities/oats-okf/lib/inspection.mjs +26 -7
  36. package/capabilities/oats-okf/lib/io.mjs +9 -1
  37. package/capabilities/oats-okf/lib/sources.mjs +34 -3
  38. package/capabilities/oats-okf/lib/stores.mjs +8 -6
  39. package/capabilities/oats-okf/lib/worker.mjs +7 -17
  40. package/capabilities/oats-okf/oats.json +6 -3
  41. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +2 -2
  42. package/capabilities/oats-okf-harvest/oats.json +3 -3
  43. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +6 -6
  44. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +37 -16
  45. package/capabilities/oats-okf-maintenance/injects/maintainer.md +1 -1
  46. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +6 -1
  47. package/capabilities/oats-okf-maintenance/oats.json +2 -2
  48. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +17 -2
  49. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +11 -23
  50. package/capabilities/oats-workspace-experts/injects/oats-experts.md +26 -0
  51. package/capabilities/oats-workspace-experts/oats.json +9 -0
  52. package/docs/capabilities.md +160 -171
  53. package/docs/capability-manifest.schema.json +7 -10
  54. package/docs/configuration.md +213 -64
  55. package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
  56. package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
  57. package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
  58. package/docs/design/2026-09-27-team-model-v2.md +116 -0
  59. package/docs/design/2026-09-28-automations-trust.md +38 -0
  60. package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
  61. package/docs/design/HISTORY.md +65 -0
  62. package/docs/design/README.md +23 -54
  63. package/docs/desktop-cli-api.md +1787 -1777
  64. package/docs/desktop.md +30 -91
  65. package/docs/execution-targets.md +146 -292
  66. package/docs/first-team.md +31 -17
  67. package/docs/implementation.md +76 -288
  68. package/docs/integrations.md +118 -320
  69. package/docs/knowledge-capability-authoring.md +25 -52
  70. package/docs/knowledge-reference/acceptance.md +3 -3
  71. package/docs/knowledge-reference/adoption.md +1 -1
  72. package/docs/knowledge-reference/harvester.md +2 -2
  73. package/docs/knowledge-reference/package-craft.md +3 -3
  74. package/docs/knowledge-reference/provider-mapping.md +3 -6
  75. package/docs/knowledge-reference/reader-capture.md +3 -3
  76. package/docs/knowledge-theory.md +62 -166
  77. package/docs/knowledge.md +225 -404
  78. package/docs/layers.md +42 -97
  79. package/docs/oats-local.schema.json +58 -5
  80. package/docs/oats-membership.schema.json +1 -8
  81. package/docs/oats-package.schema.json +5 -5
  82. package/docs/oats-workspace.schema.json +8 -22
  83. package/docs/official-catalog.md +25 -28
  84. package/docs/packages.md +45 -63
  85. package/docs/plans/0.30-close-out.md +61 -0
  86. package/docs/release-lane.md +77 -0
  87. package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
  88. package/docs/release-notes/v0.19.0.md +48 -147
  89. package/docs/release-notes/v0.19.1.md +2 -3
  90. package/docs/release-notes/v0.19.3.md +2 -15
  91. package/docs/release-notes/v0.20.0.md +0 -15
  92. package/docs/release-notes/v0.22.0.md +71 -138
  93. package/docs/release-notes/v0.22.1.md +42 -90
  94. package/docs/release-notes/v0.22.10.md +1 -1
  95. package/docs/release-notes/v0.22.11.md +1 -47
  96. package/docs/release-notes/v0.22.12.md +4 -13
  97. package/docs/release-notes/v0.22.13.md +1 -42
  98. package/docs/release-notes/v0.22.14.md +3 -11
  99. package/docs/release-notes/v0.22.15.md +1 -46
  100. package/docs/release-notes/v0.22.16.md +6 -8
  101. package/docs/release-notes/v0.22.18.md +1 -99
  102. package/docs/release-notes/v0.22.19.md +3 -14
  103. package/docs/release-notes/v0.22.2.md +6 -15
  104. package/docs/release-notes/v0.22.3.md +0 -1
  105. package/docs/release-notes/v0.22.4.md +1 -14
  106. package/docs/release-notes/v0.22.5.md +2 -12
  107. package/docs/release-notes/v0.22.6.md +0 -3
  108. package/docs/release-notes/v0.23.0.md +9 -25
  109. package/docs/release-notes/v0.23.1.md +9 -25
  110. package/docs/release-notes/v0.23.2.md +2 -4
  111. package/docs/release-notes/v0.24.0.md +56 -97
  112. package/docs/release-notes/v0.24.1.md +7 -11
  113. package/docs/release-notes/v0.24.10.md +34 -45
  114. package/docs/release-notes/v0.24.11.md +12 -20
  115. package/docs/release-notes/v0.24.12.md +35 -48
  116. package/docs/release-notes/v0.24.13.md +34 -41
  117. package/docs/release-notes/v0.24.2.md +9 -13
  118. package/docs/release-notes/v0.24.3.md +7 -11
  119. package/docs/release-notes/v0.24.4.md +6 -6
  120. package/docs/release-notes/v0.24.5.md +6 -10
  121. package/docs/release-notes/v0.24.6.md +2 -5
  122. package/docs/release-notes/v0.24.7.md +46 -75
  123. package/docs/release-notes/v0.24.8.md +58 -96
  124. package/docs/release-notes/v0.24.9.md +38 -54
  125. package/docs/release-notes/v0.25.0.md +59 -76
  126. package/docs/release-notes/v0.25.1.md +57 -81
  127. package/docs/release-notes/v0.25.2.md +51 -70
  128. package/docs/release-notes/v0.25.3.md +11 -13
  129. package/docs/release-notes/v0.25.4.md +9 -13
  130. package/docs/release-notes/v0.25.5.md +3 -5
  131. package/docs/release-notes/v0.25.6.md +20 -29
  132. package/docs/release-notes/v0.25.7.md +5 -7
  133. package/docs/release-notes/v0.25.8.md +26 -39
  134. package/docs/release-notes/v0.26.0.md +175 -646
  135. package/docs/release-notes/v0.27.0.md +4 -5
  136. package/docs/release-notes/v0.27.1.md +4 -6
  137. package/docs/release-notes/v0.27.2.md +1 -1
  138. package/docs/release-notes/v0.28.0.md +57 -124
  139. package/docs/release-notes/v0.29.0.md +89 -208
  140. package/docs/release-notes/v0.29.1.md +1 -1
  141. package/docs/release-notes/v0.29.2.md +3 -4
  142. package/docs/release-notes/v0.29.4.md +90 -0
  143. package/docs/release-notes/v0.30.0.md +205 -0
  144. package/docs/schedules.md +280 -349
  145. package/docs/servers.md +99 -117
  146. package/docs/soul.schema.json +2 -9
  147. package/docs/souls-and-instances.md +145 -158
  148. package/docs/workspaces.md +132 -215
  149. package/lib/automations.mjs +28 -6
  150. package/lib/core.mjs +226 -74
  151. package/lib/instance-events.mjs +1 -1
  152. package/lib/instance-inspect.mjs +109 -34
  153. package/lib/instance-lifecycle.mjs +14 -1
  154. package/lib/instance-resolution.mjs +26 -27
  155. package/lib/launch-preference.mjs +87 -0
  156. package/lib/materialize.mjs +3 -3
  157. package/lib/packages.mjs +2 -5
  158. package/lib/resolve.mjs +29 -87
  159. package/lib/schedule.mjs +32 -18
  160. package/lib/teams-verbs.mjs +195 -0
  161. package/lib/teams.mjs +190 -0
  162. package/lib/triggers.mjs +53 -17
  163. package/lib/workspace.mjs +54 -147
  164. package/package-catalog.json +9 -15
  165. package/package.json +1 -1
  166. package/skills/oats-getting-started/SKILL.md +25 -13
  167. package/capabilities/oats-review/injects/review.md +0 -69
  168. package/capabilities/oats-review/oats.json +0 -10
  169. package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
  170. package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
  171. package/docs/conventions.md +0 -90
  172. package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
  173. package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
  174. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
  175. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
  176. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
  177. package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
  178. package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
  179. package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
  180. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
  181. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
  182. package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
  183. package/docs/design/2026-09-15-captured-dispatch.md +0 -127
  184. package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
  185. package/docs/design/2026-09-15-package-preparation.md +0 -100
  186. package/docs/design/2026-09-15-portable-data-contract.md +0 -121
  187. package/docs/design/2026-09-15-portable-declarations.md +0 -189
  188. package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
  189. package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
  190. package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
  191. package/docs/design/2026-09-15-source-observation.md +0 -119
  192. package/docs/design/2026-09-16-captured-admission.md +0 -77
  193. package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
  194. package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
  195. package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
  196. package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
  197. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
  198. package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
  199. package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
  200. package/docs/design/2026-09-16-portable-onboarding.md +0 -179
  201. package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
  202. package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
  203. package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
  204. package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
  205. package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
  206. package/docs/design/2026-09-17-captured-native-start.md +0 -58
  207. package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
  208. package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
  209. package/docs/design/2026-09-17-public-captured-start.md +0 -108
  210. package/docs/design/2026-09-17-public-prepare-request.md +0 -90
  211. package/docs/design/2026-09-18-captured-pi-host.md +0 -205
  212. package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
  213. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
  214. package/docs/design/2026-09-20-redesign-program-board.md +0 -142
  215. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
  216. package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
  217. package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
  218. package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
  219. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
  220. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
  221. package/docs/design/2026-09-24-phase-d-plan.md +0 -305
  222. package/docs/design/2026-09-25-teams-contract.md +0 -258
  223. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
  224. package/docs/design/desktop-ux-plan.md +0 -362
  225. package/docs/design/launch-configurations.md +0 -168
  226. package/docs/design/okf-mirror-provenance.md +0 -105
  227. package/docs/design/operations-contract.md +0 -141
  228. package/docs/oats-member.schema.json +0 -38
  229. package/skills/integration-authoring/SKILL.md +0 -84
  230. package/skills/oats-support/SKILL.md +0 -79
  231. package/skills/skill-craft/SKILL.md +0 -109
  232. 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
@@ -21,7 +21,7 @@
21
21
  * succeeded. A launch whose side effects cannot be confirmed stays
22
22
  * `unknown` with its slot held until `reconcile` proves what happened. */
23
23
  import { spawnSync } from "node:child_process";
24
- import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
25
25
  import { createHash } from "node:crypto";
26
26
  import { homedir } from "node:os";
27
27
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
@@ -31,7 +31,7 @@ import { loadLocal } from "./workspace.mjs";
31
31
  import { noteRuntimeName } from "./deprecation.mjs";
32
32
  import { tickTriggers } from "./triggers.mjs";
33
33
  import { resolveMemberClone } from "./instance-resolution.mjs";
34
- import { AUTOMATION_ID_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
34
+ import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
35
35
  import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
36
36
 
37
37
  export const SCHEDULE_FILE = "oats-schedules.json";
@@ -208,6 +208,12 @@ export function readRegistry() {
208
208
  if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
209
209
  if (!Number.isInteger(reg.maxConcurrent) || reg.maxConcurrent < 1) reg.maxConcurrent = 1;
210
210
  if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
211
+ // The host cap on trigger-spawned live instances (re-review B #5): absent = unbounded (each
212
+ // trigger's own concurrency.max still applies). Separate from maxConcurrent: a running scheduled
213
+ // job never holds a trigger, and trigger homes never hold a schedule. Hand-edited, so refused loudly.
214
+ if (reg.triggersMaxConcurrent !== undefined && !(Number.isSafeInteger(reg.triggersMaxConcurrent) && reg.triggersMaxConcurrent >= 1)) {
215
+ throw scheduleError("E_SCHEDULE_INVALID", `${join(hostScheduleDir(), "registry.json")}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
216
+ }
211
217
  return reg;
212
218
  }
213
219
  export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
@@ -300,14 +306,14 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
300
306
  validateCron(def.cron, def.tz);
301
307
  const out = { id, enabled, cron: def.cron.trim(), tz: def.tz.trim(), kind: def.kind };
302
308
  if (def.kind === "spawn") {
303
- if (typeof def.agent !== "string" || !def.agent.trim()) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required", { field: "agent" });
309
+ if (typeof def.agent !== "string" || !def.agent.trim() || def.agent.trim().startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required (not an option)", { field: "agent" });
304
310
  out.agent = def.agent.trim();
305
311
  if (def.agentsRoot !== undefined) {
306
312
  const known = scopeRoots(ws);
307
313
  if (typeof def.agentsRoot !== "string" || !isAbsolute(def.agentsRoot) || !inside(ws, def.agentsRoot) || !known.includes(resolve(def.agentsRoot))) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: must be one of this scope's agents roots (${known.join(", ") || "none"})`, { field: "agentsRoot" });
308
314
  out.agentsRoot = resolve(def.agentsRoot);
309
315
  }
310
- if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
316
+ if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim() || def.repo.startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
311
317
  if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
312
318
  if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
313
319
  // A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
@@ -323,7 +329,7 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
323
329
  }
324
330
  if (def.launchConfig !== undefined) { if (typeof def.launchConfig !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(def.launchConfig)) throw scheduleError("E_SCHEDULE_INVALID", "launchConfig: the name of a launch configuration on the running host (oats-local.yaml launch-configs)", { field: "launchConfig" }); out.launchConfig = def.launchConfig; }
325
331
  if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
326
- if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
332
+ if (def.model !== undefined) { if (typeof def.model !== "string" || !MODEL_RE.test(def.model)) throw scheduleError("E_SCHEDULE_INVALID", "model: a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)", { field: "model" }); out.model = def.model; }
327
333
  if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
328
334
  if (def.wake !== undefined) {
329
335
  const w = def.wake;
@@ -402,6 +408,10 @@ export function validateWorkspaceSchedule(dep, a) {
402
408
  if (spec.kind === "command") {
403
409
  if (d.cwd !== undefined && (typeof d.cwd !== "string" || isAbsolute(d.cwd))) throw scheduleError("E_SCHEDULE_INVALID", "cwd: a directory relative to the deployment (a workspace schedule never names a machine path)", { field: "cwd" });
404
410
  spec.cwd = resolve(dep, d.cwd ?? ".");
411
+ // Inside the deployment, `..` and symlinks included (re-review B #4): Git content never picks a
412
+ // directory elsewhere on the host.
413
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
414
+ if (!inside(real(dep), real(spec.cwd))) throw scheduleError("E_SCHEDULE_INVALID", `cwd: ${JSON.stringify(d.cwd)} leaves the deployment; a workspace command's cwd is a directory inside it`, { field: "cwd" });
405
415
  }
406
416
  const out = validateDefinition(dep, spec, { checkAgent: false });
407
417
  delete out.enabled;
@@ -497,7 +507,7 @@ export function childEnv() {
497
507
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
498
508
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
499
509
  "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
500
- "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",
501
511
  ]) delete env[key];
502
512
  return env;
503
513
  }
@@ -521,19 +531,22 @@ export function workspaceSpawnContext(ws) {
521
531
  * `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
522
532
  function spawnViaCli(ws, root, agent, opts, context, io) {
523
533
  if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
524
- const argv = ["spawn", agent.name, "--dir", context.dir, ...(opts.discover ? [] : ["--agents-root", root]), "--purpose", opts.purpose, "--json"];
525
- if (opts.launchConfig) argv.push("--launch-config", opts.launchConfig);
526
- if (opts.harness) argv.push("--harness", opts.harness);
527
- if (opts.model) argv.push("--model", opts.model);
528
- if (opts.backend) argv.push("--backend", opts.backend);
529
- if (opts.repo) argv.push("--repo", opts.repo);
534
+ // Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own;
535
+ // the soul is the one positional, and an option-shaped one is refused.
536
+ if (String(agent.name).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(agent.name)} is not a soul name`, { field: "agent" });
537
+ const argv = ["spawn", agent.name, `--dir=${context.dir}`, ...(opts.discover ? [] : [`--agents-root=${root}`]), `--purpose=${opts.purpose}`, "--json"];
538
+ if (opts.launchConfig) argv.push(`--launch-config=${opts.launchConfig}`);
539
+ if (opts.harness) argv.push(`--harness=${opts.harness}`);
540
+ if (opts.model) argv.push(`--model=${opts.model}`);
541
+ if (opts.backend) argv.push(`--backend=${opts.backend}`);
542
+ if (opts.repo) argv.push(`--repo=${opts.repo}`);
530
543
  if (opts.yolo === true) argv.push("--yolo"); else if (opts.yolo === false) argv.push("--no-yolo");
531
544
  if (opts.launch === false) argv.push("--no-launch");
532
545
  // The task is multi-line text of arbitrary size: it travels as a private file, never in argv.
533
546
  mkdirSync(join(stateDir(ws), "tasks"), { recursive: true });
534
547
  const taskFile = join(stateDir(ws), "tasks", `${opts.purpose}-${process.pid}.md`);
535
548
  writeFileSync(taskFile, opts.task, { mode: 0o600 });
536
- argv.push("--task-file", taskFile);
549
+ argv.push(`--task-file=${taskFile}`);
537
550
  let r;
538
551
  try { r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
539
552
  finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
@@ -866,7 +879,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
866
879
  }
867
880
  // Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
868
881
  for (const ws of wsList) {
869
- try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws) })); }
882
+ try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws), reg, wsList })); }
870
883
  catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
871
884
  }
872
885
  if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
@@ -978,10 +991,11 @@ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {})
978
991
  const { root, discover } = resolveScheduledAgent(ws, def);
979
992
  const context = workspaceSpawnContext(ws);
980
993
  if (context.error) throw scheduleError(context.error.code, context.error.message);
981
- const argv = ["spawn", def.agent, "--dir", context.dir, ...(discover ? [] : ["--agents-root", root]), "--preview", "--json"];
982
- if (def.launchConfig) argv.push("--launch-config", def.launchConfig);
983
- if (def.harness) argv.push("--harness", def.harness);
984
- if (def.model) argv.push("--model", def.model);
994
+ if (String(def.agent).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(def.agent)} is not a soul name`, { field: "agent" });
995
+ const argv = ["spawn", def.agent, `--dir=${context.dir}`, ...(discover ? [] : [`--agents-root=${root}`]), "--preview", "--json"];
996
+ if (def.launchConfig) argv.push(`--launch-config=${def.launchConfig}`);
997
+ if (def.harness) argv.push(`--harness=${def.harness}`);
998
+ if (def.model) argv.push(`--model=${def.model}`);
985
999
  const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
986
1000
  const envelope = parseEnvelopeText(r.stdout);
987
1001
  if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
@@ -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
+ }