@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/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
@@ -28,8 +28,8 @@ import { spawnSync } from "node:child_process";
28
28
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
29
29
  import { dirname, join } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus } from "./schedule.mjs";
32
- import { automationError, baseRow, localEntry, localId, qualifiedId, soulOriginOf } from "./automations.mjs";
31
+ import { readDefinitions, writeDefinitions, stateDir, withScopeLock, childEnv, parseEnvelopeText, schedulerStatus, readRegistry } from "./schedule.mjs";
32
+ import { MODEL_RE, automationError, baseRow, localEntry, localId, parseOwner, qualifiedId, soulOriginOf } from "./automations.mjs";
33
33
  import { lockedPackageRef } from "./packages.mjs";
34
34
 
35
35
  export const TRIGGER_API = 1;
@@ -121,7 +121,7 @@ export function validateTrigger(def) {
121
121
  if (!Array.isArray(teams) || teams.some((t) => typeof t !== "string" || !LABEL_RE.test(t)) || new Set(teams).size !== teams.length) throw invalid("spawn.teams", "a list of distinct team labels");
122
122
  if (sp.launchConfig !== undefined && (typeof sp.launchConfig !== "string" || !LAUNCH_CONFIG_RE.test(sp.launchConfig))) throw invalid("spawn.launchConfig", "the name of a launch configuration on the running host (oats-local.yaml launch-configs)");
123
123
  if (sp.harness !== undefined && !HARNESSES.has(sp.harness)) throw invalid("spawn.harness", "pi, claude or codex");
124
- if (sp.model !== undefined && (typeof sp.model !== "string" || !sp.model.trim())) throw invalid("spawn.model", "a model id");
124
+ if (sp.model !== undefined && (typeof sp.model !== "string" || !MODEL_RE.test(sp.model))) throw invalid("spawn.model", "a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)");
125
125
  if (sp.yolo !== undefined && typeof sp.yolo !== "boolean") throw invalid("spawn.yolo", "boolean");
126
126
  if (sp.backend !== undefined && !BACKENDS.has(sp.backend)) throw invalid("spawn.backend", "tmux or herdr");
127
127
  const cc = def.concurrency === undefined ? {} : def.concurrency;
@@ -207,7 +207,12 @@ export function triggerKind({ lock = null, remote = null } = {}) {
207
207
  parseBody: parseTriggerBody,
208
208
  expand: async (a) => {
209
209
  const raw = a.source.from ? await packageTriggerTemplate(lock, a.source.from, a.source.set, { id: a.name, remote }) : { ...a.source.definition };
210
- return validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
210
+ const def = validateTrigger({ ...raw, id: a.name, kind: "trigger", enabled: true });
211
+ // The owner check asks gh on the OWNER's host, and the poll runs on the REPO's host: they must be
212
+ // one GitHub host, or polling would act as whoever is logged in there (re-review B #7).
213
+ const owner = parseOwner(a.owner), repo = parseRepo(def.on.repo);
214
+ if (owner && repo && owner.host.toLowerCase() !== repo.host) throw Object.assign(triggerError("E_TRIGGER_INVALID", `owner ${a.owner} is on ${owner.host}, but on.repo ${repo.key} is on ${repo.host}: a trigger acts as its owner on the repository's own GitHub host`), { field: "owner" });
215
+ return def;
211
216
  },
212
217
  };
213
218
  }
@@ -265,8 +270,28 @@ export function readTriggerState(ws) {
265
270
  let doc;
266
271
  try { doc = JSON.parse(readFileSync(file, "utf8")); } catch { doc = null; }
267
272
  if (!isObject(doc) || !isObject(doc.triggers)) return { version: 1, triggers: {} };
273
+ return carryUnqualifiedState(doc);
274
+ }
275
+ /** 0.28 kept a local trigger's state under its bare id, with event keys `<id>:…`; 0.29 qualifies
276
+ * both as `local/<id>`. Carried on read (re-review B #2): without it the first 0.29 tick saw no
277
+ * PR state, so every open matching PR emitted `opened` and spawned again. A bare id already
278
+ * present in qualified form keeps the qualified state. */
279
+ function carryUnqualifiedState(doc) {
280
+ for (const id of Object.keys(doc.triggers)) {
281
+ if (id.includes("/")) continue;
282
+ const qid = localId(id), old = doc.triggers[id];
283
+ delete doc.triggers[id];
284
+ if (doc.triggers[qid] || !isObject(old)) continue;
285
+ const rekey = (map) => Object.fromEntries(Object.entries(isObject(map) ? map : {}).map(([k, v]) => {
286
+ const key = k.startsWith(`${id}:`) ? `${qid}${k.slice(id.length)}` : k;
287
+ return [key, isObject(v) && "key" in v ? { ...v, key, ...(v.trigger === id ? { trigger: qid } : {}) } : v];
288
+ }));
289
+ doc.triggers[qid] = { ...old, prs: isObject(old.prs) ? old.prs : {}, fired: rekey(old.fired), pending: rekey(old.pending) };
290
+ }
268
291
  return doc;
269
292
  }
293
+ /** Whether an instance's recorded trigger id is `id`: a 0.28 home records a local trigger's bare id. */
294
+ const sameTrigger = (recorded, id) => recorded === id || (typeof recorded === "string" && !recorded.includes("/") && id === localId(recorded));
270
295
  function writeTriggerState(ws, st) {
271
296
  const file = statePath(ws);
272
297
  mkdirSync(dirname(file), { recursive: true });
@@ -290,7 +315,7 @@ export function liveTriggerInstances(ws, id) {
290
315
  if (!e.isDirectory()) continue;
291
316
  const home = join(instances, e.name);
292
317
  let meta; try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { continue; }
293
- if (isObject(meta?.trigger) && meta.trigger.id === id) out.push({ instance: meta.instance ?? e.name, home, repo: meta.trigger.repo, number: meta.trigger.number, event: meta.trigger.event, key: meta.trigger.key ?? null });
318
+ if (isObject(meta?.trigger) && (id === null || sameTrigger(meta.trigger.id, id))) out.push({ instance: meta.instance ?? e.name, home, repo: meta.trigger.repo, number: meta.trigger.number, event: meta.trigger.event, key: meta.trigger.key ?? null });
294
319
  }
295
320
  }
296
321
  return out;
@@ -359,6 +384,9 @@ export function foldPoll(def, ts, poll, now) {
359
384
  for (const e of eventsFor(def, ts.prs[pr.number], pr)) {
360
385
  const key = eventKey(def, pr, e);
361
386
  if (ts.fired[key] || ts.pending[key]) continue;
387
+ // A newer push supersedes a pending `synchronize` for an older head of the same PR: only
388
+ // the newest head is reviewed (re-review B #6).
389
+ if (e.event === "synchronize") for (const [k, p] of Object.entries(ts.pending)) if (p.number === pr.number && p.event === "synchronize") delete ts.pending[k];
362
390
  ts.pending[key] = { trigger: def.qid ?? def.id, source: def.on.source, repo: def.on.repo, number: pr.number, url: pr.url, event: e.event, headSha: pr.headSha, labels: pr.labels, observedAt: now.toISOString(), key };
363
391
  added.push(key);
364
392
  }
@@ -384,7 +412,7 @@ function runOats(ws, argv, io) {
384
412
  /** What `oats spawn <soul> --preview` says about the soul: its qualified resolution and the
385
413
  * capability filling its messaging slot. → { ok, result | error } */
386
414
  export function previewSoul(ws, def, io) {
387
- const r = runOats(ws, ["spawn", def.spawn.soul, "--dir", ws, "--preview", "--json"], io);
415
+ const r = runOats(ws, ["spawn", def.spawn.soul, `--dir=${ws}`, "--preview", "--json"], io);
388
416
  if (!r.envelope || typeof r.envelope.ok !== "boolean") return { ok: false, error: { code: "E_SPAWN_FAILED", message: r.timedOut ? "spawn --preview timed out" : `spawn --preview answered no envelope${r.stderr ? `: ${r.stderr.trim().split("\n").pop()}` : ""}` } };
389
417
  if (!r.envelope.ok) return { ok: false, error: { code: r.envelope.error?.code ?? "E_SPAWN_FAILED", message: r.envelope.error?.message ?? "spawn --preview failed" } };
390
418
  const modules = Array.isArray(r.envelope.result?.modules) ? r.envelope.result.modules : [];
@@ -396,17 +424,18 @@ export function previewSoul(ws, def, io) {
396
424
  export function spawnForEvent(ws, def, ev, io) {
397
425
  const fields = { repo: ev.repo, number: ev.number, url: ev.url, event: ev.event, headSha: ev.headSha, trigger: def.id };
398
426
  const purpose = renderTemplate(def.spawn.purpose, fields);
399
- const argv = ["spawn", def.spawn.soul, "--dir", ws, "--purpose", purpose, "--json"];
427
+ // Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own.
428
+ const argv = ["spawn", def.spawn.soul, `--dir=${ws}`, `--purpose=${purpose}`, "--json"];
400
429
  if (def.spawn.teams.length) {
401
430
  const pv = previewSoul(ws, def, io);
402
431
  if (!pv.ok) throw triggerError(pv.error.code, pv.error.message);
403
432
  if (!pv.messaging) throw triggerError("E_TRIGGER_TEAMS", `trigger ${def.id}: spawn.teams [${def.spawn.teams.join(", ")}] needs a messaging capability, and soul ${def.spawn.soul} resolves none`, { details: { soul: def.spawn.soul, teams: def.spawn.teams } });
404
433
  argv.push("--provider", pv.messaging, `join=${def.spawn.teams.join(",")}`);
405
434
  }
406
- if (def.spawn.launchConfig) argv.push("--launch-config", def.spawn.launchConfig);
407
- if (def.spawn.harness) argv.push("--harness", def.spawn.harness);
408
- if (def.spawn.model) argv.push("--model", def.spawn.model);
409
- if (def.spawn.backend) argv.push("--backend", def.spawn.backend);
435
+ if (def.spawn.launchConfig) argv.push(`--launch-config=${def.spawn.launchConfig}`);
436
+ if (def.spawn.harness) argv.push(`--harness=${def.spawn.harness}`);
437
+ if (def.spawn.model) argv.push(`--model=${def.spawn.model}`);
438
+ if (def.spawn.backend) argv.push(`--backend=${def.spawn.backend}`);
410
439
  if (def.spawn.yolo === true) argv.push("--yolo"); else if (def.spawn.yolo === false) argv.push("--no-yolo");
411
440
  if (io?.noLaunch === true) argv.push("--no-launch");
412
441
  // The task and the event travel as private files, never in argv.
@@ -416,7 +445,7 @@ export function spawnForEvent(ws, def, ev, io) {
416
445
  const taskFile = `${stem}.md`, eventFile = `${stem}.event.json`;
417
446
  writeFileSync(eventFile, JSON.stringify(ev, null, 2) + "\n", { mode: 0o600 });
418
447
  writeFileSync(taskFile, renderTemplate(def.spawn.task, fields).trimEnd() + triggerBlock(def, ev, "<home>/.oats/trigger-event.json"), { mode: 0o600 });
419
- argv.push("--task-file", taskFile, "--trigger-event", eventFile);
448
+ argv.push(`--task-file=${taskFile}`, `--trigger-event=${eventFile}`);
420
449
  let r;
421
450
  try { r = runOats(ws, argv, io); }
422
451
  finally { for (const f of [taskFile, eventFile]) { try { rmSync(f, { force: true }); } catch { /* best effort */ } } }
@@ -438,18 +467,24 @@ function heldBy(def, ev, live) {
438
467
  * whose poll is not due is not polled; pending events (a spawn that failed, or was held by
439
468
  * concurrency) are retried whenever the trigger polls. `dryRun` reads only.
440
469
  * → considered rows { workspace, trigger, action, … } */
441
- export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null } = {}) {
470
+ export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = null, reg = null, wsList = null } = {}) {
442
471
  let entries;
443
472
  try { entries = triggerEntries(ws, ctx); } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
444
473
  if (!entries.length) return [];
474
+ // The host cap (registry triggersMaxConcurrent; absent = unbounded) counts every trigger-spawned
475
+ // live home in the host's scopes, and nothing a schedule runs (re-review B #5).
476
+ let hostCap;
477
+ try { hostCap = (reg || readRegistry()).triggersMaxConcurrent; } catch (e) { return [{ workspace: ws, action: "error", error: e.message }]; }
478
+ const scopes = [...new Set([...(wsList || []), ws])];
479
+ let hostLive = hostCap ? scopes.reduce((n, w) => n + liveTriggerInstances(w, null).length, 0) : 0;
445
480
  const st = readTriggerState(ws);
446
481
  const considered = [];
447
482
  const rec = (id, action, extra = {}) => considered.push({ workspace: ws, trigger: id, action, ...extra });
448
483
  for (const e of entries) {
449
484
  const id = e.id;
450
485
  // A workspace trigger another host runs is not this tick's business; one NAMED for this host
451
- // that it cannot run (another gh account) is reported every tick until someone fixes it.
452
- 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; }
453
488
  if (e.invalid) { if (e.placement.enabledHere) rec(id, "invalid", { error: e.invalid.message }); continue; }
454
489
  if (!e.placement.runsHere) continue;
455
490
  const def = runDef(e);
@@ -471,15 +506,16 @@ export function tickTriggers(ws, { now = new Date(), io, dryRun = false, ctx = n
471
506
  const queue = Object.values(work.pending).sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.key.localeCompare(b.key));
472
507
  if (!queue.length) rec(id, "polled", { prs: work.lastPoll.prs, matching: work.lastPoll.matching });
473
508
  for (const ev of queue) {
474
- const held = heldBy(def, ev, live);
509
+ const held = heldBy(def, ev, live) ?? (hostCap && hostLive >= hostCap ? `host triggersMaxConcurrent ${hostCap} reached (${hostLive} live)` : null);
475
510
  if (held) { rec(id, "held", { key: ev.key, reason: held }); continue; }
476
- if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); continue; }
511
+ if (dryRun) { rec(id, "would-fire", { key: ev.key, event: ev.event, number: ev.number, url: ev.url }); live.push({ repo: ev.repo, number: ev.number }); hostLive++; continue; }
477
512
  try {
478
513
  const run = spawnForEvent(ws, def, ev, io);
479
514
  work.fired[ev.key] = { at: now.toISOString(), instance: run.instance ?? null, home: run.home ?? null, event: ev.event, number: ev.number };
480
515
  delete work.pending[ev.key];
481
516
  work.lastFiredAt = now.toISOString();
482
517
  live.push({ instance: run.instance, home: run.home, repo: ev.repo, number: ev.number });
518
+ hostLive++;
483
519
  rec(id, "fired", { key: ev.key, instance: run.instance ?? null, home: run.home ?? null });
484
520
  } catch (e) {
485
521
  work.lastError = { at: now.toISOString(), code: e.code || "E_SPAWN_FAILED", message: e.message, key: ev.key };