@awebai/oats 0.29.4 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +12 -6
  2. package/bin/oats.mjs +194 -50
  3. package/capabilities/oats-aweb/bin/oats-aweb.mjs +538 -204
  4. package/capabilities/oats-aweb/injects/aweb.md +1 -1
  5. package/capabilities/oats-aweb/lib/binding-wire.mjs +31 -22
  6. package/capabilities/oats-aweb/oats.json +5 -12
  7. package/capabilities/oats-aweb/skills/VENDORED.md +4 -4
  8. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +1 -1
  9. package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +83 -13
  10. package/capabilities/oats-code-review/injects/reviewer.md +26 -0
  11. package/capabilities/oats-code-review/oats.json +16 -0
  12. package/capabilities/oats-code-review/skills/adversarial-review/SKILL.md +66 -0
  13. package/capabilities/oats-code-review/skills/review-dev-docs/SKILL.md +30 -0
  14. package/capabilities/oats-code-review/skills/security-review/SKILL.md +56 -0
  15. package/capabilities/oats-code-review/skills/simplification-review/SKILL.md +34 -0
  16. package/capabilities/oats-developer/injects/developer.md +38 -0
  17. package/capabilities/oats-developer/oats.json +17 -0
  18. package/capabilities/oats-developer/skills/execution-strategy/SKILL.md +43 -0
  19. package/capabilities/oats-developer/skills/maintain-dev-docs/SKILL.md +47 -0
  20. package/capabilities/oats-developer/skills/run-the-review-loop/SKILL.md +65 -0
  21. package/capabilities/oats-developer/skills/understand-the-spec/SKILL.md +37 -0
  22. package/capabilities/oats-developer/skills/worktrees/SKILL.md +36 -0
  23. package/capabilities/oats-engineering-expert/injects/expert.md +37 -0
  24. package/capabilities/oats-engineering-expert/oats.json +17 -0
  25. package/capabilities/oats-engineering-expert/skills/coordinate-developers/SKILL.md +37 -0
  26. package/capabilities/oats-engineering-expert/skills/coordinate-experts/SKILL.md +52 -0
  27. package/capabilities/oats-engineering-expert/skills/land-your-prs/SKILL.md +50 -0
  28. package/capabilities/oats-engineering-expert/skills/plan-and-spec/SKILL.md +53 -0
  29. package/capabilities/oats-engineering-expert/skills/verify-developer-work/SKILL.md +49 -0
  30. package/capabilities/oats-okf/bin/oats-okf.mjs +8 -4
  31. package/capabilities/oats-okf/lib/binding-wire.mjs +47 -15
  32. package/capabilities/oats-okf/lib/inspection.mjs +26 -7
  33. package/capabilities/oats-okf/lib/sources.mjs +16 -2
  34. package/capabilities/oats-okf/lib/worker.mjs +5 -16
  35. package/capabilities/oats-okf/oats.json +6 -3
  36. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +2 -2
  37. package/capabilities/oats-okf-harvest/oats.json +3 -3
  38. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +6 -6
  39. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +2 -2
  40. package/capabilities/oats-okf-maintenance/injects/maintainer.md +1 -1
  41. package/capabilities/oats-okf-maintenance/oats.json +2 -2
  42. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +1 -1
  43. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +11 -23
  44. package/capabilities/oats-workspace-experts/injects/oats-experts.md +26 -0
  45. package/capabilities/oats-workspace-experts/oats.json +9 -0
  46. package/docs/capabilities.md +160 -171
  47. package/docs/capability-manifest.schema.json +6 -11
  48. package/docs/configuration.md +213 -64
  49. package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
  50. package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
  51. package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
  52. package/docs/design/2026-09-27-team-model-v2.md +97 -117
  53. package/docs/design/2026-09-28-automations-trust.md +38 -0
  54. package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
  55. package/docs/design/HISTORY.md +65 -0
  56. package/docs/design/README.md +23 -54
  57. package/docs/desktop-cli-api.md +1787 -1777
  58. package/docs/desktop.md +30 -91
  59. package/docs/execution-targets.md +146 -292
  60. package/docs/first-team.md +31 -17
  61. package/docs/implementation.md +76 -288
  62. package/docs/integrations.md +118 -320
  63. package/docs/knowledge-capability-authoring.md +25 -52
  64. package/docs/knowledge-reference/acceptance.md +3 -3
  65. package/docs/knowledge-reference/adoption.md +1 -1
  66. package/docs/knowledge-reference/harvester.md +2 -2
  67. package/docs/knowledge-reference/package-craft.md +3 -3
  68. package/docs/knowledge-reference/provider-mapping.md +3 -6
  69. package/docs/knowledge-reference/reader-capture.md +3 -3
  70. package/docs/knowledge-theory.md +62 -166
  71. package/docs/knowledge.md +225 -404
  72. package/docs/layers.md +42 -97
  73. package/docs/oats-local.schema.json +58 -5
  74. package/docs/oats-membership.schema.json +1 -8
  75. package/docs/oats-package.schema.json +5 -5
  76. package/docs/oats-workspace.schema.json +8 -22
  77. package/docs/official-catalog.md +25 -28
  78. package/docs/packages.md +45 -63
  79. package/docs/plans/0.30-close-out.md +61 -0
  80. package/docs/release-lane.md +77 -0
  81. package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
  82. package/docs/release-notes/v0.19.0.md +48 -147
  83. package/docs/release-notes/v0.19.1.md +2 -3
  84. package/docs/release-notes/v0.19.3.md +2 -15
  85. package/docs/release-notes/v0.20.0.md +0 -15
  86. package/docs/release-notes/v0.22.0.md +71 -138
  87. package/docs/release-notes/v0.22.1.md +42 -90
  88. package/docs/release-notes/v0.22.10.md +1 -1
  89. package/docs/release-notes/v0.22.11.md +1 -47
  90. package/docs/release-notes/v0.22.12.md +4 -13
  91. package/docs/release-notes/v0.22.13.md +1 -42
  92. package/docs/release-notes/v0.22.14.md +3 -11
  93. package/docs/release-notes/v0.22.15.md +1 -46
  94. package/docs/release-notes/v0.22.16.md +6 -8
  95. package/docs/release-notes/v0.22.18.md +1 -99
  96. package/docs/release-notes/v0.22.19.md +3 -14
  97. package/docs/release-notes/v0.22.2.md +6 -15
  98. package/docs/release-notes/v0.22.3.md +0 -1
  99. package/docs/release-notes/v0.22.4.md +1 -14
  100. package/docs/release-notes/v0.22.5.md +2 -12
  101. package/docs/release-notes/v0.22.6.md +0 -3
  102. package/docs/release-notes/v0.23.0.md +9 -25
  103. package/docs/release-notes/v0.23.1.md +9 -25
  104. package/docs/release-notes/v0.23.2.md +2 -4
  105. package/docs/release-notes/v0.24.0.md +56 -97
  106. package/docs/release-notes/v0.24.1.md +7 -11
  107. package/docs/release-notes/v0.24.10.md +34 -45
  108. package/docs/release-notes/v0.24.11.md +12 -20
  109. package/docs/release-notes/v0.24.12.md +35 -48
  110. package/docs/release-notes/v0.24.13.md +34 -41
  111. package/docs/release-notes/v0.24.2.md +9 -13
  112. package/docs/release-notes/v0.24.3.md +7 -11
  113. package/docs/release-notes/v0.24.4.md +6 -6
  114. package/docs/release-notes/v0.24.5.md +6 -10
  115. package/docs/release-notes/v0.24.6.md +2 -5
  116. package/docs/release-notes/v0.24.7.md +46 -75
  117. package/docs/release-notes/v0.24.8.md +58 -96
  118. package/docs/release-notes/v0.24.9.md +38 -54
  119. package/docs/release-notes/v0.25.0.md +59 -76
  120. package/docs/release-notes/v0.25.1.md +57 -81
  121. package/docs/release-notes/v0.25.2.md +51 -70
  122. package/docs/release-notes/v0.25.3.md +11 -13
  123. package/docs/release-notes/v0.25.4.md +9 -13
  124. package/docs/release-notes/v0.25.5.md +3 -5
  125. package/docs/release-notes/v0.25.6.md +20 -29
  126. package/docs/release-notes/v0.25.7.md +5 -7
  127. package/docs/release-notes/v0.25.8.md +26 -39
  128. package/docs/release-notes/v0.26.0.md +175 -646
  129. package/docs/release-notes/v0.27.0.md +4 -5
  130. package/docs/release-notes/v0.27.1.md +4 -6
  131. package/docs/release-notes/v0.27.2.md +1 -1
  132. package/docs/release-notes/v0.28.0.md +57 -124
  133. package/docs/release-notes/v0.29.0.md +89 -208
  134. package/docs/release-notes/v0.29.1.md +1 -1
  135. package/docs/release-notes/v0.29.2.md +3 -4
  136. package/docs/release-notes/v0.30.0.md +205 -0
  137. package/docs/schedules.md +280 -363
  138. package/docs/servers.md +99 -117
  139. package/docs/soul.schema.json +2 -9
  140. package/docs/souls-and-instances.md +145 -158
  141. package/docs/workspaces.md +132 -215
  142. package/lib/automations.mjs +21 -6
  143. package/lib/core.mjs +226 -74
  144. package/lib/instance-events.mjs +1 -1
  145. package/lib/instance-inspect.mjs +109 -34
  146. package/lib/instance-lifecycle.mjs +14 -1
  147. package/lib/instance-resolution.mjs +26 -27
  148. package/lib/launch-preference.mjs +87 -0
  149. package/lib/materialize.mjs +3 -3
  150. package/lib/resolve.mjs +29 -87
  151. package/lib/schedule.mjs +1 -1
  152. package/lib/teams-verbs.mjs +195 -0
  153. package/lib/teams.mjs +190 -0
  154. package/lib/triggers.mjs +2 -2
  155. package/lib/workspace.mjs +54 -147
  156. package/package-catalog.json +9 -15
  157. package/package.json +1 -1
  158. package/skills/oats-getting-started/SKILL.md +25 -13
  159. package/capabilities/oats-review/injects/review.md +0 -69
  160. package/capabilities/oats-review/oats.json +0 -10
  161. package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
  162. package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
  163. package/docs/conventions.md +0 -90
  164. package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
  165. package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
  166. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
  167. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
  168. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
  169. package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
  170. package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
  171. package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
  172. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
  173. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
  174. package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
  175. package/docs/design/2026-09-15-captured-dispatch.md +0 -127
  176. package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
  177. package/docs/design/2026-09-15-package-preparation.md +0 -100
  178. package/docs/design/2026-09-15-portable-data-contract.md +0 -121
  179. package/docs/design/2026-09-15-portable-declarations.md +0 -189
  180. package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
  181. package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
  182. package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
  183. package/docs/design/2026-09-15-source-observation.md +0 -119
  184. package/docs/design/2026-09-16-captured-admission.md +0 -77
  185. package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
  186. package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
  187. package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
  188. package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
  189. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
  190. package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
  191. package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
  192. package/docs/design/2026-09-16-portable-onboarding.md +0 -179
  193. package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
  194. package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
  195. package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
  196. package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
  197. package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
  198. package/docs/design/2026-09-17-captured-native-start.md +0 -58
  199. package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
  200. package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
  201. package/docs/design/2026-09-17-public-captured-start.md +0 -108
  202. package/docs/design/2026-09-17-public-prepare-request.md +0 -90
  203. package/docs/design/2026-09-18-captured-pi-host.md +0 -205
  204. package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
  205. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
  206. package/docs/design/2026-09-20-redesign-program-board.md +0 -142
  207. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
  208. package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
  209. package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
  210. package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
  211. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
  212. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
  213. package/docs/design/2026-09-24-phase-d-plan.md +0 -305
  214. package/docs/design/2026-09-25-teams-contract.md +0 -258
  215. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
  216. package/docs/design/desktop-ux-plan.md +0 -362
  217. package/docs/design/launch-configurations.md +0 -168
  218. package/docs/design/okf-mirror-provenance.md +0 -105
  219. package/docs/design/operations-contract.md +0 -141
  220. package/docs/oats-member.schema.json +0 -38
  221. package/skills/integration-authoring/SKILL.md +0 -84
  222. package/skills/oats-support/SKILL.md +0 -79
  223. package/skills/skill-craft/SKILL.md +0 -109
  224. package/skills/soul-craft/SKILL.md +0 -116
@@ -14,10 +14,12 @@ import { spawnSync } from "node:child_process";
14
14
  import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { agentDirOf, discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
17
+ import { capabilityManifests, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
+ import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
- import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
20
+ import { kernelCompatibility } from "./resolve.mjs";
21
+ import { recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
22
+ import { launchLayers, launchReport } from "./launch-preference.mjs";
21
23
  import { loadLocal } from "./workspace.mjs";
22
24
  import { ensureModuleTree } from "./operator-dispatch.mjs";
23
25
  /** Bounds of a provider check's answer (bytes, nesting, entries), as the released binding wire set them. */
@@ -34,7 +36,9 @@ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p);
34
36
 
35
37
  /** A resolution refusal that is a readiness FACT (the soul cannot resolve as the
36
38
  * lock stands), not a failure to answer: reported as a failing `installed` item. */
37
- export const RESOLUTION_FACTS = new Set(["E_PACKAGE_MISSING", "E_PACKAGE_INTEGRITY", "E_CAPABILITY_MISSING", "E_LOCK_SCHEMA", "E_REQUIREMENT_INACTIVE"]);
39
+ export const RESOLUTION_FACTS = new Set(["E_PACKAGE_MISSING", "E_PACKAGE_INTEGRITY", "E_CAPABILITY_MISSING", "E_LOCK_SCHEMA", "E_REQUIREMENT_INACTIVE", "E_TEAM_UNKNOWN", "E_TEAM_NOT_ELIGIBLE"]);
40
+ /** A resolution refusal that readiness reports as a team item (checks.configured), not an install failure. */
41
+ const TEAM_FACTS = new Set(["E_TEAM_UNKNOWN", "E_TEAM_NOT_ELIGIBLE"]);
38
42
 
39
43
  /** Is `name` an executable on PATH? (manifest `requires`, no shell). */
40
44
  function onPath(name) {
@@ -54,7 +58,7 @@ const missingRequires = (manifest) => manifestRequires(manifest).filter((r) => !
54
58
  function declarationsOf(definition) {
55
59
  const d = obj(definition) ? definition : {};
56
60
  const section = (key) => (d[key] === undefined ? null : d[key]);
57
- return { requires: section("requires"), defaults: section("defaults"), knowledge: section("knowledge"), teams: section("teams"), resources: section("resources"), children: section("children"), capabilities: section("capabilities") };
61
+ return { requires: section("requires"), defaults: section("defaults"), knowledge: section("knowledge"), resources: section("resources"), children: section("children"), capabilities: section("capabilities") };
58
62
  }
59
63
  function readSoulYaml(soulDir) {
60
64
  const file = soulDir && join(soulDir, "soul.yaml");
@@ -68,9 +72,9 @@ function readTextCapped(file, cap = 200_000) {
68
72
  }
69
73
 
70
74
  /** The instance target: `home` is absolute and holds instance.json with modules.
71
- * `teams` is the home's LIVE eligible teams (teams contract decision 6), read from this
72
- * target's discovery when it discovers, else by liveTeams; `teamsSource` says which
73
- * ("live" | "recorded": the spawn-time record, when the workspace cannot answer now).
75
+ * `teams` / `defaultTeam` are the home's LIVE teams (team model v2), read from this target's
76
+ * discovery when it discovers, else by liveTeams; `teamsSource` says which ("live" |
77
+ * "recorded": the spawn-time record, when the workspace cannot answer now).
74
78
  * `live: false` skips the live read — a caller that never hands the teams to the
75
79
  * messaging provider (a non-messaging operation) gets the record at no remote cost. */
76
80
  export async function homeTarget(home, meta, { remoteOptions, discover = true, live: readLive = true } = {}) {
@@ -88,12 +92,13 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
88
92
  const ws = obj(meta.workspace) ? meta.workspace : {};
89
93
  const soulDir = instanceSoulDir(realHome, meta) ?? null;
90
94
  const { definition, problems } = readSoulYaml(soulDir);
91
- let discovery = null, discoveryError = null;
95
+ let discovery = null, discoveryError = null, local = null;
92
96
  if (discover) {
93
97
  try {
94
98
  // The derived deployment exactly — never an oats-local.yaml found further up.
95
99
  const found = loadLocal(deployment);
96
100
  if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
101
+ local = found.local;
97
102
  discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
98
103
  }
99
104
  catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
@@ -106,18 +111,26 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
106
111
  if (external === null && (discovery.members || []).some((m) => m.key === ws.soul?.repoKey)) external = false;
107
112
  }
108
113
  const slots = Object.fromEntries(LAYERS.map((l) => [l, Object.keys(modules).find((n) => manifests[n]?.layer === l) ?? null]));
109
- let live;
114
+ // The home's soul key (souls.teams / souls.default): its name, or <package>/<soul> for a package soul.
115
+ const teamKey = ws.soul?.package && typeof ws.soul.qualifiedName === "string" ? ws.soul.qualifiedName : meta.agent;
116
+ let live, model = null;
110
117
  if (discovery) {
111
- let entry = null; try { entry = findSoulEntry(discovery, meta.agent); } catch { /* no longer listed */ }
112
- live = entry && entry.repoKey === ws.soul?.repoKey
113
- ? { teams: teamsOf(discovery.standalone === true ? null : discovery.workspace, teamLabelsOf(entry)), source: "live" }
114
- : { teams: Array.isArray(meta.teams) ? meta.teams : null, source: "recorded" };
118
+ model = teamModel(discovery.standalone === true ? null : discovery.workspace, local, { workspaceKey: discovery.key ?? null });
119
+ try { const t = soulTeams(model, teamKey); live = { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" }; }
120
+ catch (e) { if (!String(e?.code).startsWith("E_TEAM_")) throw e; live = { ...recordedTeams(meta), source: "recorded" }; }
115
121
  } else if (readLive) live = await liveTeams(realHome, meta, { remoteOptions });
116
- else live = { teams: Array.isArray(meta.teams) ? meta.teams : null, source: "recorded" };
122
+ else live = { ...recordedTeams(meta), source: "recorded" };
123
+ // Feature launch-preference: the recorded launch (frozen), and what --reselect-launch would choose now —
124
+ // from the home's recorded soul and this deployment's oats-local.yaml (null when it cannot be read).
125
+ const launch = recordedLaunch(meta);
126
+ let launchCurrent = null;
127
+ try { launchCurrent = launchReportFor({ layers: homeLaunchLayers(realHome, meta), launchConfigs: launchConfigsAt(deployment), contextDir: deployment }); }
128
+ catch { launchCurrent = null; }
117
129
  return {
118
- kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, teamsSource: live.source,
130
+ kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, defaultTeam: live.defaultTeam ?? null, teamsSource: live.source, launch, launchCurrent,
131
+ recordedDefaultTeam: recordedTeams(meta).defaultTeam, teamModel: model, teamKey,
119
132
  subject: { kind: "instance", instance: meta.instance, home, soul: meta.agent },
120
- soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, team: ws.soul?.team ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
133
+ soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
121
134
  workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
122
135
  modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
123
136
  payloads: obj(meta.providers) ? meta.providers : {},
@@ -146,14 +159,34 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
146
159
  soulEntry = findSoulEntry(discovery, soul);
147
160
  }
148
161
  const res = prepared?.resolution;
149
- // A refused resolution still names its eligible teams: the labels and the workspace are known.
150
- const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
151
- const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
162
+ // A refused resolution still names the soul's teams when they resolve (a package refusal, …); a
163
+ // refusal of the teams themselves (E_TEAM_UNKNOWN / E_TEAM_NOT_ELIGIBLE) names none.
164
+ const model = teamModel(discovery?.standalone === true ? null : discovery?.workspace ?? null, prepared?.local ?? found.local, { workspaceKey: discovery?.key ?? null });
165
+ const teamKey = soulKeyOf(soulEntry);
166
+ let teams = res?.teams ?? null, defaultTeam = res ? res.defaultTeam ?? null : null;
167
+ if (!res) { try { const t = soulTeams(model, teamKey); teams = reportRows(t.teams); defaultTeam = t.defaultTeam; } catch (e) { if (!String(e?.code).startsWith("E_TEAM_")) throw e; } }
168
+ // The soul being asked about is materialised as a spawn would (ensureWorkspaceSoul: idempotent per
169
+ // commit, never touching a directory an instance links), so its checks always get OATS_SOUL; only a
170
+ // spawn wrote the per-commit copy before, and a provider asked about a soul not yet spawned at this
171
+ // commit answered a false negative. A failure is a readiness fact (soulCopyError), never a silent null.
172
+ // A refused resolution has nothing to materialise: the cached copy, when there is one.
173
+ let soulDir = null, soulCopyError = null;
174
+ if (prepared) {
175
+ try { soulDir = await ensureWorkspaceSoul(prepared, join(deployment, "agents")); }
176
+ catch (e) { soulCopyError = { code: e?.code || "E_SOUL_COPY_FAILED", message: e?.message || String(e) }; }
177
+ } else {
178
+ const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
179
+ soulDir = cached && existsSync(join(cached, "soul.yaml")) ? cached : null;
180
+ }
181
+ // Feature launch-preference: what a spawn with no flags would launch here.
182
+ let launchConfigs = {};
183
+ try { launchConfigs = launchConfigsAt(deployment); } catch { launchConfigs = {}; }
184
+ const launch = launchReportFor({ layers: prepared?.launch ?? launchLayers({ definition: soulEntry.definition, entry: soulEntry, key: teamKey, local: found.local }), launchConfigs, contextDir: deployment });
152
185
  return {
153
- kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
154
- subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },
155
- soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
156
- soulDir: cached && existsSync(join(cached, "soul.yaml")) ? cached : null, definition: soulEntry.definition ?? null, problems: [] },
186
+ kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch,
187
+ subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
188
+ soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
189
+ soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}) },
157
190
  workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
158
191
  modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
159
192
  payloads: obj(res?.payloads) ? res.payloads : {}, payloadOrigins: obj(res?.payloadOrigins) ? res.payloadOrigins : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
@@ -195,7 +228,7 @@ function capabilityRows(t) {
195
228
  }
196
229
  function soulRow(t) {
197
230
  const s = t.soul, def = obj(s.definition) ? s.definition : {};
198
- return { soulsApi: SOULS_API, name: s.name, repoKey: s.repoKey, commit: s.commit, team: s.team, kind: s.external === true ? "external" : s.external === false ? "member" : null, path: s.path,
231
+ return { soulsApi: SOULS_API, name: s.name, repoKey: s.repoKey, commit: s.commit, kind: s.external === true ? "external" : s.external === false ? "member" : null, path: s.path,
199
232
  description: def.description ?? null, work: def.work ?? null, harness: def.harness ?? null, model: def.model ?? null,
200
233
  declarations: declarationsOf(s.definition), declarationProblems: s.problems,
201
234
  instructions: s.soulDir ? readTextCapped(join(s.soulDir, "AGENTS.md")) : null };
@@ -215,20 +248,57 @@ export function inspectDocument(t, { kernel }) {
215
248
  // The capabilities the soul turned off (feature desktop-facts): its `off` over a workspace/team entry
216
249
  // (reason "off"), or a `<slot>: none` that dropped a workspace default (reason "slot-none", slot).
217
250
  capabilitiesOff: (t.turnedOff || []).map(({ name, reason, slot, overrides }) => ({ id: name, off: true, from: "soul", reason, ...(slot ? { slot } : {}), overrides })),
218
- // Teams contract item 7 (feature `teams`): the ELIGIBLE teams, one per soul label in order —
219
- // live for a home (`teamsSource: "recorded"` when its workspace could not be read now).
220
- teams: t.teams ?? null, teamsSource: t.teamsSource ?? null,
251
+ // Team model v2 (feature team-model-2): the soul's teams here and its default — live for a home
252
+ // (`teamsSource: "recorded"` when its workspace could not be read now), which also answers the
253
+ // default it was spawned with.
254
+ teams: t.teams ?? null, defaultTeam: t.defaultTeam ?? null, teamsSource: t.teamsSource ?? null,
255
+ ...(t.home ? { recordedDefaultTeam: t.recordedDefaultTeam ?? null } : {}),
256
+ // Feature launch-preference: a soul's launch here; a home's recorded launch and launchCurrent.
257
+ launch: t.launch ?? null,
258
+ ...(t.home ? { launchCurrent: t.launchCurrent ?? null } : {}),
221
259
  knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
222
260
  instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null, launched: !!m.launched, createdAt: m.createdAt || null,
223
261
  resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
224
262
  ...(m ? { identity: servedIdentityOf(m) } : {}),
225
- problems: [...t.soul.problems, ...(t.discoveryError ? [t.discoveryError] : []),
263
+ problems: [...t.soul.problems, ...(t.soul.copyError ? [t.soul.copyError] : []), ...(t.discoveryError ? [t.discoveryError] : []),
226
264
  ...t.modules.filter((x) => !x.manifest).map((x) => ({ code: "module-missing", message: `${x.name} is recorded but its module copy has no readable oats.json`, capability: x.name })),
227
265
  ...capabilities.filter((c) => !c.compatibility.ok).map((c) => ({ code: "capability-incompatible", message: `${c.id} requires oats ${c.compatibility.range}; this kernel is ${c.compatibility.kernel} (re-spawn from the deployment with a release whose range admits it)`, capability: c.id, range: c.compatibility.range, kernel: c.compatibility.kernel }))],
228
266
  };
229
267
  }
230
268
 
231
269
  // ---------- readiness ----------
270
+ /** The soul's team problems as readiness items in `checks.configured` (team model v2; no fifth check:
271
+ * released Desktops recompute the summary from the four). A failure blocks `ready`; a warning is
272
+ * `required: false`. A home also compares its spawn-time default with the live one. */
273
+ function teamItems(t) {
274
+ if (!t.teamModel) return [];
275
+ const problems = teamProblems(t.teamModel, { key: t.teamKey, messaging: !!t.slots?.messaging });
276
+ const same = (a, b) => (a?.label ?? null) === (b?.label ?? null) && (a?.team ?? null) === (b?.team ?? null);
277
+ if (t.home && t.teamsSource === "live" && t.meta?.defaultTeam !== undefined && !same(t.recordedDefaultTeam, t.defaultTeam)) {
278
+ problems.push({ code: "default-team-changed", severity: "warning", recorded: t.recordedDefaultTeam, current: t.defaultTeam,
279
+ message: `this instance was spawned in ${t.recordedDefaultTeam?.label ?? "no default team"}; the default is now ${t.defaultTeam?.label ?? "none"}`, fix: "respawn" });
280
+ }
281
+ return problems.map(({ code, severity, message, fix, ...rest }) => item(rest.label ? `team ${rest.label}` : "teams", "fail",
282
+ { required: severity === "failure", producer: "team model", code, reason: message, remedy: fix, ...rest }));
283
+ }
284
+ /** The home's recorded launch as a `Launch` (from instance.json; `from: "recorded"` for a home from before 0.30). */
285
+ function recordedLaunch(meta) {
286
+ const recipe = obj(meta?.launch) ? meta.launch : {};
287
+ return launchReport({ declared: obj(meta?.launchDeclared) ? meta.launchDeclared : null, harness: meta?.harness || recipe.harness || null, model: meta?.model || null,
288
+ launchConfig: recipe.launchConfig ?? null, from: typeof meta?.launchFrom === "string" ? meta.launchFrom : "recorded", at: meta?.launchAt ?? null });
289
+ }
290
+ /** `launch-changed` (feature launch-preference; `--home` only): a home whose launch a LAYER chose (not explicit
291
+ * flags, not a pre-0.30 record) now reads a different launch from the layers. A warning: the home keeps its
292
+ * recorded launch until `--reselect-launch` or a respawn. */
293
+ function launchItems(t) {
294
+ if (!t.home || !t.launch || !t.launchCurrent || !["local", "local-default", "soul", "host"].includes(t.launch.from)) return [];
295
+ const a = t.launch.effective, b = t.launchCurrent.effective;
296
+ if (a.harness === b.harness && a.model === b.model && a.launchConfig === b.launchConfig) return [];
297
+ const show = (e) => `${e.harness ?? "?"}${e.model ? ` ${e.model}` : ""}${e.launchConfig ? ` (launch configuration ${e.launchConfig})` : ""}`;
298
+ return [item("launch", "fail", { required: false, producer: "launch preference", code: "launch-changed",
299
+ reason: `this instance runs ${show(a)}; its launch preference now says ${show(b)} (${t.launchCurrent.at ?? t.launchCurrent.from})`,
300
+ remedy: "`oats session restart --reselect-launch`, or respawn", recorded: a, current: b, from: t.launchCurrent.from, at: t.launchCurrent.at })];
301
+ }
232
302
  function roll(items) {
233
303
  const req = items.filter((i) => i.required !== false);
234
304
  if (!items.length) return "not-applicable";
@@ -241,7 +311,7 @@ const item = (subject, status, { required = true, reason = null, producer = "ker
241
311
  /** The team/workspace facts a provider receives, as hooks get them (teamEnv). */
242
312
  function providerEnv(t, capability, settings) {
243
313
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(OATS_|OAS_|PI_)/.test(k)));
244
- Object.assign(env, teamEnv({ workspace: { key: t.workspace.key, name: t.workspace.name, deployment: t.deployment, team: t.soul.team, slots: t.slots }, payloads: t.payloads, teams: t.teams, teamsSource: t.teamsSource }));
314
+ Object.assign(env, teamEnv({ workspace: { key: t.workspace.key, name: t.workspace.name, deployment: t.deployment }, teams: t.teams, defaultTeam: t.defaultTeam, teamsSource: t.teamsSource }));
245
315
  return Object.assign(env, { OATS_CAPABILITY: capability, OATS_SETTINGS: JSON.stringify(settings), OATS_SETTINGS_ORIGINS: JSON.stringify(t.payloadOrigins?.[capability] ?? {}), OATS_CLI_BIN: CLI_BIN, OATS_WORKSPACE: t.deployment,
246
316
  ...(t.home ? { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home } : {}), OATS_AGENT: t.soul.name, ...(t.soul.soulDir ? { OATS_SOUL: t.soul.soulDir } : {}) });
247
317
  }
@@ -276,10 +346,10 @@ export function runProviderCheck(t, mod, dir, { timeoutMs = 30000 } = {}) {
276
346
  if (!file) return unavailable("resource-not-found", `${mod.name}: the check executable ${script} is unavailable`);
277
347
  const settings = obj(t.payloads[mod.name]) ? t.payloads[mod.name] : {};
278
348
  // The released binding wire, key for key: providers decode it strictly (oats.aweb 1.13.1
279
- // refuses any other key). The eligible teams reach the check through its environment
280
- // (OATS_TEAMS / OATS_TEAMS_SOURCE / OATS_TEAM_LABELS, providerEnv), never on stdin.
349
+ // refuses any other key). The teams reach the check through its environment
350
+ // (OATS_DEFAULT_TEAM* / OATS_TEAMS / OATS_TEAMS_SOURCE, providerEnv), never on stdin.
281
351
  const request = { schemaVersion: 1, phase: "check", slot: m.layer ?? null, capability: mod.name, settings,
282
- input: { context: { kind: "workspace", workspace: t.workspace.key, deployment: t.deployment, soul: t.soul.name, team: t.soul.team, instance: t.meta?.instance ?? null, home: t.home }, action: { kind: "readiness" } } };
352
+ input: { context: { kind: "workspace", workspace: t.workspace.key, deployment: t.deployment, soul: t.soul.name, instance: t.meta?.instance ?? null, home: t.home }, action: { kind: "readiness" } } };
283
353
  // The environment is providerEnv's: ambient OATS_/OAS_/PI_ identity removed, the rest
284
354
  // inherited.
285
355
  const r = spawnSync(process.execPath, [file, ...args], { cwd: dir, env: providerEnv(t, mod.name, settings), input: JSON.stringify(request), encoding: "utf8", timeout: Math.max(1, timeoutMs), killSignal: "SIGKILL", maxBuffer: BINDING_LIMITS.maxBytes, stdio: ["pipe", "pipe", "pipe"] });
@@ -315,9 +385,13 @@ export const PROVIDER_CHECK_BUDGET_MS = 60_000;
315
385
  export async function readinessDocument(t, { selector = null, remoteOptions, catalog = null, budgetMs = PROVIDER_CHECK_BUDGET_MS } = {}) {
316
386
  const installed = [], configured = [], member = [], providers = [];
317
387
  const cap = (id) => ({ capability: { id } });
318
- if (t.resolutionError) {
388
+ if (t.resolutionError && !TEAM_FACTS.has(t.resolutionError.code)) {
319
389
  installed.push(item(t.soul.name, "fail", { producer: "workspace resolution", code: t.resolutionError.code, reason: t.resolutionError.message, evidence: t.resolutionError.details, remedy: "oats sync (the lock must provide every package capability the soul resolves)" }));
320
390
  }
391
+ if (t.soul.copyError) {
392
+ installed.push(item(`soul ${t.soul.name}`, "fail", { producer: "soul copy", code: t.soul.copyError.code, reason: `the soul could not be copied into ${join(t.agentsRoot, "…", "souls")}: ${t.soul.copyError.message}`,
393
+ evidence: { repoKey: t.soul.repoKey, commit: t.soul.commit }, remedy: "check access to the soul's repository at the locked commit (oats sync), then retry" }));
394
+ }
321
395
  for (const mod of t.modules) {
322
396
  const present = t.home ? existsSync(join(mod.dir, "oats.json")) && !!mod.manifest : !!mod.manifest;
323
397
  installed.push(item(mod.name, present ? "pass" : "fail", { producer: t.home ? "instance modules" : "workspace resolution", evidence: { from: mod.from },
@@ -328,6 +402,7 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
328
402
  evidence: { command: req.command }, remedy: found ? null : req.install, ...cap(mod.name) }));
329
403
  }
330
404
  }
405
+ configured.push(...teamItems(t), ...launchItems(t));
331
406
  // member — the soul's member repository is a confirmed member of the workspace
332
407
  // (oats-membership.yaml backlink observed over the remotes).
333
408
  const d = t.discovery;
@@ -10,7 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { basename, join } from "node:path";
13
- import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, retirePendingMarkerPath, stopInstanceSession } from "./core.mjs";
13
+ import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, stopInstanceSession } from "./core.mjs";
14
14
  import { observeInstanceGit } from "./instance-git.mjs";
15
15
  import { appendEvent } from "./instance-events.mjs";
16
16
  import { oatsError } from "./errors.mjs";
@@ -74,6 +74,17 @@ function sessionFacts(home) {
74
74
  try { const s = inspectInstanceSession(home); return { state: s.state, present: s.present, backend: s.backend ?? null, established: true }; }
75
75
  catch (e) { return { state: "unestablished", present: null, backend: null, established: false, reason: e.code || "E_SESSION_UNAVAILABLE" }; }
76
76
  }
77
+ /** Retire's view of a home without its session receipt (before 0.25.9): the
78
+ * retirement needs only absence, which it can observe — `{state: "absent",
79
+ * present: false, established: true, note}`; live or ambiguous stays
80
+ * unestablished, with the note saying why (retire refuses it). */
81
+ function retireSessionFacts(home, session) {
82
+ if (session.established || session.reason !== "E_RUNTIME_ENDPOINT_UNKNOWN") return session;
83
+ const meta = readJson(join(home, "instance.json"));
84
+ if (!meta) return session;
85
+ const seen = observeSessionWithoutReceipt(home, meta);
86
+ return seen.absent ? { state: "absent", present: false, backend: seen.backend, established: true, note: seen.note } : { ...session, note: seen.note };
87
+ }
77
88
  const running = (s) => s.established && s.present === true && !["shell", "stopped", "not-launched"].includes(s.state);
78
89
  function workFacts(home) {
79
90
  if (!existsSync(join(home, "work"))) return { observed: false, reason: "no-worktree" };
@@ -161,6 +172,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
161
172
  const me = resolveInstance(ctx, root, name, { home });
162
173
  const kids = descendantsOf(me.root, name);
163
174
  const target = targetFacts({ ...me, instance: name, depth: 0 });
175
+ target.session = retireSessionFacts(me.home, target.session);
164
176
  const meta = readJson(join(me.home, "instance.json")) || {};
165
177
  const facts = { session: target.session, work: target.work, workMode: meta.work ?? null, repo: meta.repo ?? null, recordedBranch: meta.branch ?? null,
166
178
  children: kids.map((c) => ({ instance: c.instance, agent: c.agent, home: c.home, session: sessionFacts(c.home) })),
@@ -175,6 +187,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
175
187
  ...(facts.work.observed && facts.work.changed + facts.work.untracked > 0 ? [`${facts.work.changed} changed and ${facts.work.untracked} untracked file(s) would be retained with the worktree`] : []),
176
188
  ...(kids.length ? [`${kids.length} recorded child instance(s) are stopped first (bounded SIGTERM, never escalated) and retained (their homes are not removed); a child still running after the grace refuses the retirement`] : []),
177
189
  ...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
190
+ ...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
178
191
  "pull request state is unknown to the kernel; the ADE reports it when a forge connection exists",
179
192
  ] };
180
193
  }
@@ -19,8 +19,10 @@
19
19
  import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
20
20
  import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from "node:path";
21
21
  import { oatsError } from "./errors.mjs";
22
- import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace, observeSoulLabels } from "./workspace.mjs";
23
- import { resolveSoul, teamsOf, kernelCompatibility } from "./resolve.mjs";
22
+ import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
23
+ import { resolveSoul, kernelCompatibility } from "./resolve.mjs";
24
+ import { BY_TEAM_REMOVED, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
25
+ import { launchLayers } from "./launch-preference.mjs";
24
26
  import { declaredSettings } from "./capability-contract.mjs";
25
27
  import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
26
28
  import { fetchRemoteTree } from "./remote.mjs";
@@ -39,14 +41,13 @@ function err(code, message, details) { const e = oatsError(code, message, detail
39
41
  * Same set as lib/resolve.mjs POISON_KEYS; refused with E_WORKSPACE_SCHEMA reason
40
42
  * "poison-key" so a `--provider constructor x=1` never reaches Object.prototype. */
41
43
  const POISON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
42
- /** `byTeam` is reserved: legal ONLY at the top level of workspace.messaging (decision 23);
43
- * a spawn payload may never smuggle it in. */
44
+ /** `byTeam` was removed in 0.30 (team model v2): a spawn payload may not carry it. */
44
45
  const RESERVED_KEY = "byTeam";
45
46
 
46
47
  /** Parse repeated `--provider <cap> k=v` occurrences into { <cap>: { k: v } }.
47
48
  * Values are strings; `k=v=w` keeps everything after the first `=`. Poison keys
48
49
  * (capability OR any path segment) are E_WORKSPACE_SCHEMA reason "poison-key";
49
- * `byTeam` (any path segment) is E_WORKSPACE_SCHEMA reason "reserved-key". Both are
50
+ * `byTeam` (any path segment) is E_WORKSPACE_SCHEMA reason "removed-key". Both are
50
51
  * refused again in resolve (defense in depth). The accumulators are built with
51
52
  * Object.create(null) and only OWN keys are ever reused, so a name that happens to
52
53
  * exist on Object.prototype (`toString`, `hasOwnProperty`) never writes through to it. */
@@ -63,7 +64,7 @@ export function parseProviderFlags(pairs) {
63
64
  const parts = key.split(".");
64
65
  for (const p of parts) {
65
66
  if (POISON_KEYS.has(p)) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (segment ${JSON.stringify(p)} would poison the payload's prototype)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "poison-key" });
66
- if (p === RESERVED_KEY) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (${JSON.stringify(RESERVED_KEY)} is reserved for the top level of workspace.messaging)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "reserved-key" });
67
+ if (p === RESERVED_KEY) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (${BY_TEAM_REMOVED})`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "removed-key" });
67
68
  }
68
69
  if (!Object.hasOwn(out, cap)) out[cap] = Object.create(null);
69
70
  let cur = out[cap];
@@ -130,33 +131,29 @@ export function disabledEntry(local, soulEntry) {
130
131
  }
131
132
 
132
133
  /**
133
- * A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
134
- * state, not frozen composition: the soul's labels are read from its repository NOW and each
135
- * label's payload from the workspace's current `messaging`. The home's modules and skills are
136
- * untouched. No workspace discovery: exactly two repository reads — the workspace host at its
137
- * current commit (observeWorkspace) and the soul's own repo (observeSoulLabels).
138
- * → { teams, source: "live" } — or, when that cannot answer (the host or the soul's repo is
139
- * unreachable, the soul is no longer listed, a standalone deployment), the spawn-time record:
140
- * { teams: <record, or null when none>, source: "recorded", reason }.
134
+ * A home's LIVE teams (team model v2): the committed shared teams (the workspace host read now) and the
135
+ * deployment's oats-local.yaml as it is now, resolved for the home's soul key (its name, or
136
+ * `<package>/<soul>`). The home's modules and skills are untouched. One repository read, the workspace
137
+ * host; a standalone deployment has local teams only and reads none.
138
+ * → { teams, defaultTeam, source: "live" } — or, when that cannot answer (the host is unreachable, the
139
+ * deployment or its teams are unreadable), the spawn-time record: { teams, defaultTeam, source: "recorded", reason }.
141
140
  * `source` travels as OATS_TEAMS_SOURCE: a provider leaves a joined team only on a `live` answer.
142
141
  */
143
142
  export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
144
- const recorded = (reason, error) => ({ teams: Array.isArray(meta?.teams) ? meta.teams : null, source: "recorded", reason, ...(error ? { error } : {}) });
143
+ const recorded = (reason, error) => ({ ...recordedTeams(meta), source: "recorded", reason, ...(error ? { error } : {}) });
145
144
  const soul = meta?.workspace?.soul;
146
- // A home without a workspace soul record (a pre-0.29 capability agent's) has nothing to re-read.
147
- if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
148
- // A package soul's labels are pinned with the package: the spawn-time record is its answer.
149
- if (soul.package && typeof soul.package === "object") return recorded("package-soul");
145
+ // A home without a workspace soul record has nothing to resolve.
146
+ if (!soul || typeof meta.agent !== "string") return recorded("no-workspace-soul");
147
+ const key = soul.package && typeof soul.qualifiedName === "string" ? soul.qualifiedName : meta.agent;
150
148
  const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
151
149
  try {
152
150
  const found = loadLocal(deployment);
153
151
  if (!found.path || resolvePath(dirname(found.path)) !== resolvePath(deployment)) return recorded("no-deployment");
154
- if (typeof found.local.standalone === "string" && found.local.standalone) return recorded("standalone");
155
- const wsObs = await observeWorkspace(found.local.workspace, { remoteOptions, remote });
156
- const labels = await observeSoulLabels(wsObs, { name: meta.agent, repoKey: soul.repoKey }, { remoteOptions, remote });
157
- if (labels === null) return recorded("soul-not-listed");
158
- return { teams: teamsOf(wsObs.workspace, labels), source: "live" };
159
- } catch (e) { return recorded("unreadable", { code: e?.code || "E_REMOTE_UNREADABLE", message: e?.message ?? String(e) }); }
152
+ const standalone = typeof found.local.standalone === "string" && found.local.standalone;
153
+ const wsObs = standalone ? null : await observeWorkspace(found.local.workspace, { remoteOptions, remote });
154
+ const t = soulTeams(teamModel(wsObs?.workspace ?? null, found.local, { workspaceKey: wsObs?.key ?? null }), key);
155
+ return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam, source: "live" };
156
+ } catch (e) { return recorded(String(e?.code).startsWith("E_TEAM_") ? "invalid-teams" : "unreadable", { code: e?.code || "E_REMOTE_UNREADABLE", message: e?.message ?? String(e) }); }
160
157
  }
161
158
 
162
159
  /** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
@@ -425,7 +422,7 @@ export function requireMemberClone(prepared, { explicit } = {}) {
425
422
  }
426
423
 
427
424
  /** Would a spawn of this discovered soul be refused before it touches this machine (feature desktop-facts)?
428
- * The same checks prepareInstance makes — souls.disabled, then the soul's resolution (team conflicts, slot
425
+ * The same checks prepareInstance makes — souls.disabled, then the soul's resolution (its teams here, slot
429
426
  * conflicts, missing or private capabilities, compatibility …) — without spawning anything.
430
427
  * → { spawnable: true, problem: null } | { spawnable: false, problem: { code, message } } */
431
428
  export async function soulSpawnability(local, discovery, lock, soulEntry, { remoteOptions, remote } = {}) {
@@ -447,7 +444,9 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
447
444
  const disabled = disabledEntry(local, soulEntry);
448
445
  if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
449
446
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
450
- return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
447
+ // The launch layers a new selection reads (feature launch-preference): souls.launch, the soul's own launch.
448
+ const launch = launchLayers({ definition: soulEntry.definition, entry: soulEntry, key: soulKeyOf(soulEntry), local });
449
+ return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn, launch };
451
450
  }
452
451
 
453
452
  /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Soul launch preferences (0.30, feature `launch-preference`; docs/design/2026-09-28-soul-launch-preference.md,
3
+ * docs/desktop-cli-api.md "Launch preferences"). PURE: no I/O. A soul may declare `launch: {harness, model?}`;
4
+ * a machine overrides it in oats-local.yaml `souls.launch` (a soul key or "*" → a launch configuration's name
5
+ * or an inline {harness, model?}). For a NEW selection the first layer with a value decides: the flags, then
6
+ * souls.launch.<key>, souls.launch."*", the soul's launch, the host default. A home's recorded launch stays
7
+ * frozen: a plain start/restart never re-reads these layers (only --reselect-launch or a respawn does).
8
+ */
9
+ import { oatsError } from "./errors.mjs";
10
+
11
+ export const LOCAL_FILE = "oats-local.yaml";
12
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
13
+ /** A coded error whose details also sit on `details` (the CLI boundary renders them). */
14
+ function fail(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
15
+ const pointerKey = (k) => String(k).replace(/~/g, "~0").replace(/\//g, "~1");
16
+
17
+ /** A preference value as `{harness, model}` (`model` null when absent); anything else → null. */
18
+ export function preferenceOf(v) {
19
+ return isObject(v) && typeof v.harness === "string" ? { harness: v.harness, model: typeof v.model === "string" && v.model ? v.model : null } : null;
20
+ }
21
+
22
+ /** Where a soul's own `launch` lives: `<repoKey>:<path>/soul.yaml#/launch`, or `package:<id>:<path>/…` for a package soul. */
23
+ export function soulLaunchAt(entry) {
24
+ const file = `${entry?.path ? `${entry.path}/` : ""}soul.yaml#/launch`;
25
+ const pkg = typeof entry?.package === "string" ? entry.package : entry?.package?.id;
26
+ return pkg ? `package:${pkg}:${file}` : `${entry?.repoKey ?? "?"}:${file}`;
27
+ }
28
+
29
+ /**
30
+ * The layers a new selection reads, without flags: `declared` (the soul's own launch, or null) and `layer`
31
+ * — the deciding one, `{ from: "local" | "local-default" | "soul", at, value }` (`value` a launch
32
+ * configuration's name or `{harness, model}`) — or null (the host default).
33
+ * `definition` is the soul.yaml content; `key` its soul key (soulKeyOf); `local` the oats-local.yaml value.
34
+ */
35
+ export function launchLayers({ definition, entry, key, local }) {
36
+ const declared = preferenceOf(definition?.launch);
37
+ const byKey = isObject(local?.souls?.launch) ? local.souls.launch : {};
38
+ const pick = (k, from) => {
39
+ if (!Object.hasOwn(byKey, k)) return null;
40
+ const v = byKey[k];
41
+ return { from, at: `${LOCAL_FILE}#/souls/launch/${pointerKey(k)}`, value: typeof v === "string" ? v : preferenceOf(v) };
42
+ };
43
+ const layer = (key !== undefined && key !== "*" ? pick(key, "local") : null) ?? pick("*", "local-default")
44
+ ?? (declared ? { from: "soul", at: soulLaunchAt(entry), value: declared } : null);
45
+ return { declared, layer };
46
+ }
47
+
48
+ /**
49
+ * The selection a NEW launch makes from the flags and the layers: `{ selection, preference, from, at }` for
50
+ * resolveLaunchSelection. `--launch-config` or `--harness` decides (`from: "flag"`); otherwise the layer does
51
+ * (a name selects that configuration; an inline or soul preference is `preference`), and `--model` alone
52
+ * replaces only the model. No layer: the host default (`from: "host"`).
53
+ */
54
+ export function selectionFrom({ flags = {}, layers }) {
55
+ const selection = { launchConfig: flags.launchConfig, harness: flags.harness, model: flags.model };
56
+ if (flags.launchConfig !== undefined || flags.harness !== undefined) return { selection, preference: null, from: "flag", at: null };
57
+ const layer = layers?.layer;
58
+ if (!layer) return { selection, preference: null, from: "host", at: null };
59
+ if (typeof layer.value === "string") return { selection: { ...selection, launchConfig: layer.value }, preference: null, from: layer.from, at: layer.at };
60
+ return { selection: { ...selection, launchConfig: "none" }, preference: { ...layer.value, from: layer.from }, from: layer.from, at: layer.at };
61
+ }
62
+
63
+ /** A launch configuration the layer names but this oats-local.yaml does not declare. */
64
+ export function launchConfigUnknown({ name, from, at }) {
65
+ return fail("E_LAUNCH_CONFIG_UNKNOWN", `${at ?? "the launch preference"} names launch configuration ${JSON.stringify(name)}, which ${LOCAL_FILE} does not declare (launch-configs:); oats launch-config list shows what is`, { name, from, at });
66
+ }
67
+
68
+ /** The fix for a missing harness, by the layer that chose it. */
69
+ export function harnessFix(harness, from) {
70
+ if (from === "flag") return `install ${harness}, or choose another --harness / --launch-config`;
71
+ if (from === "local" || from === "local-default") return `install ${harness}, or change ${LOCAL_FILE} souls.launch`;
72
+ return `install ${harness}, or override it on this machine in ${LOCAL_FILE} souls.launch`;
73
+ }
74
+ /** E_HARNESS_UNAVAILABLE: the chosen harness has no executable here. Never a fallback to another harness. */
75
+ export function harnessUnavailable({ harness, from, at, why }) {
76
+ const source = from === "flag" ? "the spawn flags" : from === "host" ? "the host default" : at ?? from;
77
+ const fix = harnessFix(harness, from);
78
+ return fail("E_HARNESS_UNAVAILABLE", `${harness} is not installed on this machine (${why}); ${source} chose it — ${fix}`, { harness, from, at: at ?? null, fix });
79
+ }
80
+
81
+ /** The closed `Launch` report: `{declared, effective: {harness, model, launchConfig}, from, at, problem}`. */
82
+ export function launchReport({ declared = null, harness, model = null, launchConfig = null, from, at = null, problem = null }) {
83
+ return { declared: declared ? { harness: declared.harness, model: declared.model ?? null } : null, effective: { harness, model: model || null, launchConfig: launchConfig || null }, from, at: at ?? null, problem };
84
+ }
85
+
86
+ /** `modelFrom` for a model a preference layer supplied. */
87
+ export const MODEL_SOURCE = { soul: "soul preference", local: "local preference", "local-default": "local-default preference" };
@@ -576,7 +576,7 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
576
576
  * → { name, repoKey, commit, current: { commit } | null, status: "current"|"moved"|"missing", reason? } | null
577
577
  *
578
578
  * Decision 17 for the SOUL SOURCE: a workspace spawn records `instance.json.workspace.soul =
579
- * { repoKey, commit, team }` (the member and commit the soul was fetched from). The member row
579
+ * { repoKey, commit }` (the member and commit the soul was fetched from). The member row
580
580
  * for `repoKey` in `discovery.members` is the current state: a different commit → "moved", the
581
581
  * soul gone from the member → "missing" (reason "soul-absent"), no usable row → "missing"
582
582
  * (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
@@ -595,7 +595,7 @@ export function soulDriftOf(instanceJson, discovery) {
595
595
  // A package soul: "moved" means the package pin moved (another version/commit is locked now).
596
596
  const pkg = { package: soul.package.id, version: soul.package.version ?? null };
597
597
  const soulName = typeof soul.name === "string" ? soul.name : name;
598
- const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
598
+ const base = { name: soulName, repoKey: soul.repoKey, commit, ...pkg };
599
599
  const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
600
600
  const now = listed.find((s) => s.name === soulName) || null;
601
601
  if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
@@ -604,7 +604,7 @@ export function soulDriftOf(instanceJson, discovery) {
604
604
  const members = Array.isArray(discovery?.members) ? discovery.members : [];
605
605
  const member = members.find((m) => m && m.key === soul.repoKey) || null;
606
606
  const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
607
- const base = { name, repoKey: soul.repoKey, commit, team: soul.team ?? null };
607
+ const base = { name, repoKey: soul.repoKey, commit };
608
608
  if (!member || (!member.confirmed && !standaloneOwn) || typeof member.commit !== "string") {
609
609
  return { ...base, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" };
610
610
  }