@awebai/oats 0.25.9 → 0.27.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 (183) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +648 -1755
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +8 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-review/oats.json +3 -2
  20. package/docs/capabilities.md +229 -58
  21. package/docs/capability-manifest.schema.json +29 -9
  22. package/docs/configuration.md +17 -5
  23. package/docs/conventions.md +18 -28
  24. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  25. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  26. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  27. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  28. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  29. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  30. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  31. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  32. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
  33. package/docs/design/2026-09-24-phase-d-plan.md +57 -0
  34. package/docs/design/2026-09-25-teams-contract.md +226 -0
  35. package/docs/design/README.md +3 -3
  36. package/docs/design/launch-configurations.md +20 -16
  37. package/docs/design/operations-contract.md +27 -10
  38. package/docs/desktop-cli-api.md +604 -271
  39. package/docs/desktop-instance-start.md +3 -3
  40. package/docs/desktop.md +7 -13
  41. package/docs/execution-targets.md +16 -18
  42. package/docs/first-team.md +15 -18
  43. package/docs/implementation.md +31 -62
  44. package/docs/integrations.md +64 -33
  45. package/docs/knowledge-capability-authoring.md +1 -1
  46. package/docs/knowledge-reference/package-craft.md +10 -8
  47. package/docs/knowledge-theory.md +1 -1
  48. package/docs/knowledge.md +10 -11
  49. package/docs/layers.md +16 -17
  50. package/docs/oats-local.schema.json +30 -1
  51. package/docs/oats-membership.schema.json +5 -3
  52. package/docs/oats-package.schema.json +2 -2
  53. package/docs/oats-workspace.schema.json +1 -1
  54. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  55. package/docs/packages.md +76 -53
  56. package/docs/release-notes/v0.22.0.md +1 -1
  57. package/docs/release-notes/v0.23.1.md +1 -1
  58. package/docs/release-notes/v0.26.0.md +670 -0
  59. package/docs/release-notes/v0.27.0.md +100 -0
  60. package/docs/schedules.md +54 -132
  61. package/docs/servers.md +4 -4
  62. package/docs/soul.schema.json +11 -4
  63. package/docs/souls-and-instances.md +60 -47
  64. package/docs/workspaces.md +80 -58
  65. package/injects/instance-boundary.md +2 -2
  66. package/injects/work-attached.md +1 -1
  67. package/injects/work-workspace.md +2 -2
  68. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  69. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  70. package/lib/capability-contract.mjs +110 -0
  71. package/lib/config-data.mjs +2 -2
  72. package/lib/core.mjs +947 -5023
  73. package/lib/deprecation.mjs +24 -0
  74. package/lib/digest.mjs +12 -0
  75. package/lib/instance-inspect.mjs +397 -0
  76. package/lib/instance-lifecycle.mjs +3 -4
  77. package/lib/instance-resolution.mjs +212 -26
  78. package/lib/instruction-composition.mjs +0 -20
  79. package/lib/materialize.mjs +6 -4
  80. package/lib/operator-dispatch.mjs +33 -13
  81. package/lib/packages.mjs +25 -190
  82. package/lib/process-group.mjs +1 -1
  83. package/lib/provider-binding.mjs +4 -2
  84. package/lib/provider-reasons.mjs +3 -68
  85. package/lib/remote.mjs +1 -1
  86. package/lib/resolve.mjs +204 -68
  87. package/lib/schedule.mjs +136 -292
  88. package/lib/servers.mjs +70 -38
  89. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  90. package/lib/tree-copy.mjs +44 -0
  91. package/lib/workspace.mjs +132 -20
  92. package/package-catalog.json +6 -6
  93. package/package.json +1 -1
  94. package/packages/record/lib/session-roots.mjs +8 -6
  95. package/skills/integration-authoring/SKILL.md +48 -40
  96. package/skills/oats-getting-started/SKILL.md +105 -110
  97. package/skills/oats-support/SKILL.md +2 -2
  98. package/skills/soul-craft/SKILL.md +13 -6
  99. package/bin/oats-pi-sdk-host.mjs +0 -17
  100. package/docs/2026-09-03-architecture-proposal.md +0 -642
  101. package/docs/artifact-approvals.schema.json +0 -7
  102. package/docs/captured-invocation-context.schema.json +0 -7
  103. package/docs/captured-resolution.schema.json +0 -7
  104. package/docs/design/package-engine-contract.md +0 -813
  105. package/docs/design/package-runtime-api.md +0 -588
  106. package/docs/desktop-succession.md +0 -57
  107. package/docs/execution-capsule.schema.json +0 -108
  108. package/docs/first-team-demo.md +0 -92
  109. package/docs/knowledge-migration.md +0 -147
  110. package/docs/migration-from-oas.md +0 -103
  111. package/docs/oats-config.schema.json +0 -172
  112. package/docs/oats-lock-v3.schema.json +0 -7
  113. package/docs/oats-lock.schema.json +0 -175
  114. package/docs/operating-team-migration.md +0 -470
  115. package/docs/portable.schema.json +0 -2512
  116. package/docs/provider-check-input.schema.json +0 -7
  117. package/docs/rebuild-to-v2.md +0 -511
  118. package/docs/workspace-adoption.md +0 -74
  119. package/injects/framework-workspace.md +0 -7
  120. package/injects/local-soul.md +0 -19
  121. package/injects/oats-portable.md +0 -20
  122. package/injects/oats.md +0 -11
  123. package/injects/portable-instance-boundary.md +0 -39
  124. package/injects/portable-work-directory.md +0 -29
  125. package/lib/artifact-approvals.mjs +0 -120
  126. package/lib/artifact-tree.mjs +0 -141
  127. package/lib/capability-artifacts.mjs +0 -179
  128. package/lib/capability-execution.mjs +0 -15
  129. package/lib/capability-inputs.mjs +0 -39
  130. package/lib/capability-provenance.mjs +0 -231
  131. package/lib/captured-action-shape.mjs +0 -21
  132. package/lib/captured-admission-shape.mjs +0 -20
  133. package/lib/captured-binding-file.mjs +0 -36
  134. package/lib/captured-dispatch.mjs +0 -66
  135. package/lib/captured-instance-index.mjs +0 -277
  136. package/lib/captured-invocation-context.mjs +0 -130
  137. package/lib/captured-launch-request.mjs +0 -66
  138. package/lib/captured-operation-process.mjs +0 -15
  139. package/lib/captured-pi-custody.mjs +0 -29
  140. package/lib/captured-pi-host.mjs +0 -167
  141. package/lib/captured-pi-outcome.mjs +0 -172
  142. package/lib/captured-resolutions.mjs +0 -275
  143. package/lib/captured-scaffold.mjs +0 -87
  144. package/lib/captured-selector.mjs +0 -28
  145. package/lib/captured-session-backend.mjs +0 -52
  146. package/lib/captured-source-receipt-file.mjs +0 -72
  147. package/lib/helper-injection-policy.mjs +0 -104
  148. package/lib/legacy-lock-codec.mjs +0 -106
  149. package/lib/manifest-settings.mjs +0 -84
  150. package/lib/package-closure.mjs +0 -48
  151. package/lib/package-materialization.mjs +0 -83
  152. package/lib/pi-sdk-host.mjs +0 -229
  153. package/lib/portable-artifacts.mjs +0 -115
  154. package/lib/portable-choices.mjs +0 -82
  155. package/lib/portable-composition.mjs +0 -136
  156. package/lib/portable-digest.mjs +0 -105
  157. package/lib/portable-identity.mjs +0 -40
  158. package/lib/portable-lock.mjs +0 -117
  159. package/lib/portable-onboarding-request.mjs +0 -49
  160. package/lib/portable-onboarding.mjs +0 -256
  161. package/lib/portable-package-preparation.mjs +0 -188
  162. package/lib/portable-policy.mjs +0 -44
  163. package/lib/portable-soul.mjs +0 -42
  164. package/lib/portable-state.mjs +0 -80
  165. package/lib/prepare-composition.mjs +0 -170
  166. package/lib/prepared-bindings.mjs +0 -92
  167. package/lib/prepared-resources.mjs +0 -127
  168. package/lib/provider-binding-broker.mjs +0 -65
  169. package/lib/provider-binding-wire.mjs +0 -116
  170. package/lib/readiness.mjs +0 -225
  171. package/lib/repository-observation.mjs +0 -226
  172. package/lib/resolution-shape.mjs +0 -393
  173. package/lib/schedule-capsule.mjs +0 -206
  174. package/lib/soul-constraints.mjs +0 -40
  175. package/lib/source-projection.mjs +0 -84
  176. package/lib/source-spec.mjs +0 -189
  177. package/lib/workspace-definition.mjs +0 -126
  178. package/lib/workspace-discovery.mjs +0 -146
  179. package/skills/oats/SKILL.md +0 -162
  180. package/skills/oats-config/SKILL.md +0 -164
  181. package/skills/oats-packages/SKILL.md +0 -184
  182. package/skills/oats-portable/SKILL.md +0 -115
  183. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/servers.mjs CHANGED
@@ -21,8 +21,8 @@ import { createHash } from "node:crypto";
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { join, resolve } from "node:path";
24
- import { parseStrictJson } from "./portable-values.mjs";
25
- import { capturedSelector } from "./captured-selector.mjs";
24
+ import { parseStrictJson } from "./canonical-json.mjs";
25
+ import { noteRuntimeName } from "./deprecation.mjs";
26
26
 
27
27
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
28
28
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -225,53 +225,64 @@ export function checkRemote(target, io = {}) {
225
225
  }
226
226
  // What the remote kernel advertises it can launch. A probe without these
227
227
  // fields is a 0.22.1-class kernel: pi and claude only, no session backend
228
- // choice, no launch options; nothing newer may be requested of it.
228
+ // choice, no launch options; nothing newer may be requested of it. The
229
+ // harness list is named `harnesses` since 0.27.0; an older kernel names the
230
+ // same list `runtimes`, and is read in its own vocabulary (lead call 6).
229
231
  const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
232
+ const harnessKey = Array.isArray(probe.harnesses) ? "harnesses" : "runtimes";
230
233
  return {
231
234
  version: probe.version, schemaVersion: 1, desktopApi: 1,
232
- runtimes: list("runtimes", ["pi", "claude"]),
235
+ harnesses: list(harnessKey, ["pi", "claude"]),
233
236
  sessionBackends: list("sessionBackends", []),
234
237
  launchOptions: list("launchOptions", []),
235
238
  remote: list("remote", []),
236
239
  features: list("features", []),
237
- operationsApi: probe.operationsApi === 1 ? 1 : null,
240
+ operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
238
241
  scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
239
- advertised: Array.isArray(probe.runtimes),
242
+ advertised: Array.isArray(probe[harnessKey]),
240
243
  };
241
244
  }
242
245
 
243
246
  /** Refuse, before any mutation, a spawn that asks the remote kernel for a
244
- * runtime, session backend or launch option it does not advertise. The
245
- * effective runtime is the --runtime flag or the soul's default from the
247
+ * harness, session backend or launch option it does not advertise. The
248
+ * effective harness is the --harness flag or the soul's default from the
246
249
  * remote roster, resolved THERE, since the local roster says nothing about
247
250
  * that host's souls. Desktop's local support check cannot prove remote
248
251
  * support; this is the proof. */
252
+ /** The client speaks the host's vocabulary (0.27.0, lead call 6): a host that does not
253
+ * advertise the harness feature is asked with `--runtime`, the name it reads. */
254
+ export function hostHarnessArgs(remote, args) {
255
+ if ((remote?.features || []).includes("harness")) return args;
256
+ return args.map((a) => (a === "--harness" ? "--runtime" : a.startsWith("--harness=") ? `--runtime=${a.slice("--harness=".length)}` : a));
257
+ }
249
258
  export function checkRemoteSupport(remote, target, oatsArgs, roster) {
250
259
  const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
251
260
  const agent = oatsArgs.find((a) => !a.startsWith("--"));
252
261
  const soul = (roster?.agents || []).find((a) => a.name === agent);
253
- // The effective runtime is the flag, else the soul's default as the remote
254
- // roster reports it. A soul the roster does not list with a runtime (a
262
+ // The effective harness is the flag, else the soul's default as the remote
263
+ // roster reports it. A soul the roster does not list with a harness (a
255
264
  // capability-defined agent, or one with no live instance) has no default
256
265
  // this side can establish: the remote kernel validates its own default at
257
266
  // spawn, and nothing is asserted here about it.
258
267
  const namedConfig = oatsArgs.includes("--launch-config");
259
- const runtime = flagOf("--runtime") || (namedConfig ? undefined : soul?.runtime);
268
+ // --runtime is the pre-0.27 name of --harness; a roster row from a host before 0.27 says `runtime`.
269
+ const chosen = flagOf("--harness") || flagOf("--runtime");
270
+ const harness = chosen || (namedConfig ? undefined : soul?.harness ?? soul?.runtime);
260
271
  // What the message may claim depends on what was established: an
261
272
  // advertising remote said what it supports; a silent one (before 0.22.2)
262
273
  // said nothing, and only pi and claude are assumed of it.
263
274
  const supports = remote.advertised
264
- ? `it advertises runtimes ${remote.runtimes.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
265
- : `it does not advertise what it supports (kernels before 0.22.2 do not), so only pi and claude on tmux with no launch options are assumed of it; upgrade it there to use more`;
275
+ ? `it advertises harnesses ${remote.harnesses.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
276
+ : `it does not advertise its harnesses (kernels before 0.22.2 do not), so only pi and claude are assumed of it${remote.sessionBackends.length ? `, with session backends ${remote.sessionBackends.join(", ")}` : " on tmux"}${remote.launchOptions.length ? ` and launch options ${remote.launchOptions.join(", ")}` : " with no launch options"}; upgrade it there to use more`;
266
277
  const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
267
278
  if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
268
- if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
279
+ if (harness && !remote.harnesses.includes(harness)) refuse(`harness ${harness}${chosen ? "" : ` (the default of soul ${agent} there)`}`);
269
280
  // A wake schedule at spawn is saved by the host: it must advertise schedules.
270
281
  if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
271
282
  const backend = flagOf("--backend");
272
283
  if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
273
284
  if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
274
- return { runtime, backend, yolo: oatsArgs.includes("--yolo") };
285
+ return { harness, backend, yolo: oatsArgs.includes("--yolo") };
275
286
  }
276
287
 
277
288
  /** The remote kernel version that carries `oats session` (inspect, input,
@@ -405,14 +416,13 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
405
416
  const ii = oatsArgs.indexOf("--instance");
406
417
  const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
407
418
  if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
408
- // The remote roster: the soul's runtime default for the support check,
419
+ // The remote roster: the soul's harness default for the support check,
409
420
  // and the remote agents root for the snapshot (the kernel's spawn result
410
- // does not carry it, and guessing it from the workspace would be wrong
411
- // for local souls).
421
+ // does not carry it, and guessing it from the workspace would be wrong).
412
422
  const status = runRemote(target, json(withScope(["status"])), io).envelope;
413
423
  if (!status.ok) return { envelope: status, stderr: "" };
414
424
  checkRemoteSupport(remote, target, oatsArgs, status.result);
415
- const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
425
+ const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...hostHarnessArgs(remote, oatsArgs)])), io);
416
426
  if (envelope.ok && envelope.result?.instance) {
417
427
  // A generated name can still collide with a saved route of another
418
428
  // soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
@@ -486,7 +496,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
486
496
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
487
497
  }
488
498
  if (OPERATIONS_COMMANDS.has(cmd)) {
489
- // The operations contract (inspect, operation run, use, soul set): the
499
+ // The operations contract (inspect, operation run): the
490
500
  // remote must advertise it before anything is sent. An explicit --dir is
491
501
  // the exact member context the caller chose and travels as is; without
492
502
  // one, a --home selection is left to the host (the home is its own
@@ -505,14 +515,14 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
505
515
  if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
506
516
  }
507
517
  const remote = checkRemote(target, io);
508
- if (!Array.isArray(remote.features) || !remote.features.includes("operations") || remote.operationsApi !== 1) {
518
+ if (!Array.isArray(remote.features) || !remote.features.includes("operations") || ![1, 2].includes(remote.operationsApi)) {
509
519
  throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the operations contract (kernels from ${OPERATIONS_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
510
520
  }
511
521
  const scoped = args.includes("--dir") || args.includes("--home") ? args : [...args, "--dir", target.workspace];
512
522
  const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
513
523
  return { envelope: envelope.result && typeof envelope.result === "object" ? { ...envelope, result: { ...envelope.result, server: serverId, ...(route ? { route: { home: route.home, instance: route.snapshot?.instance || null, frozen: !!route.snapshot } } : {}) } } : envelope, stderr };
514
524
  }
515
- throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect, operation, use and soul only (not ${cmd})`);
525
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect and operation only (not ${cmd})`);
516
526
  }
517
527
 
518
528
  // ------------------------------------------------------------------- roster
@@ -578,7 +588,7 @@ export function rosterGroups({ server, io = {} } = {}) {
578
588
  g.probe = { ok: true };
579
589
  g.agentsRoot = status.result.root;
580
590
  for (const a of status.result.agents || []) {
581
- g.souls.push({ name: a.name, runtime: a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
591
+ g.souls.push({ name: a.name, harness: a.harness ?? a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
582
592
  // A failed deferred self-retirement needs an operator: it rides with
583
593
  // the group, named by agent and instance, as `oats status` prints it.
584
594
  for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
@@ -586,7 +596,7 @@ export function rosterGroups({ server, io = {} } = {}) {
586
596
  const snap = routeOf(i);
587
597
  g.instances.push({
588
598
  server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
589
- runtime: i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
599
+ harness: i.harness || i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
590
600
  ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
591
601
  running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
592
602
  retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
@@ -599,7 +609,7 @@ export function rosterGroups({ server, io = {} } = {}) {
599
609
  // instance is gone there (retired, or its home removed) and the route is
600
610
  // stale; with a failed probe nothing is known. Same keys as remote rows.
601
611
  for (const snap of bySnapshot.values()) {
602
- g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, runtime: snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
612
+ g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
603
613
  }
604
614
  delete g._snapshots;
605
615
  }
@@ -656,7 +666,7 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
656
666
  export const SESSION_START_REMOTE_VERSION = "0.22.9";
657
667
  /** The kernel version whose probe first advertises the operations contract. */
658
668
  export const OPERATIONS_REMOTE_VERSION = "0.22.16";
659
- const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
669
+ const OPERATIONS_COMMANDS = new Set(["inspect", "operation"]);
660
670
 
661
671
  /** `session start` on the execution host for a remote instance: the same
662
672
  * route resolution as inspect, refused before any mutation when the remote
@@ -669,15 +679,15 @@ export function restartRemote(serverId, choices = {}, io = {}) {
669
679
  return launchRemoteSession(serverId, "restart", choices, io);
670
680
  }
671
681
 
672
- function remoteLaunchArgs({ launchConfig, runtime, model, yolo } = {}) {
682
+ function remoteLaunchArgs({ launchConfig, harness, model, yolo } = {}) {
673
683
  const args = [];
674
684
  if (launchConfig !== undefined) {
675
685
  if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
676
686
  args.push("--launch-config", launchConfig);
677
687
  }
678
- if (runtime !== undefined) {
679
- if (!["pi", "claude", "codex"].includes(runtime)) throw serverError("E_BAD_ARGS", "runtime must be pi, claude or codex");
680
- args.push("--runtime", runtime);
688
+ if (harness !== undefined) {
689
+ if (!["pi", "claude", "codex"].includes(harness)) throw serverError("E_BAD_ARGS", "harness must be pi, claude or codex");
690
+ args.push("--harness", harness);
681
691
  }
682
692
  if (model !== undefined && model !== null && model !== "") {
683
693
  if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
@@ -695,7 +705,7 @@ function requireRemoteFeature(remote, target, feature) {
695
705
  }
696
706
 
697
707
  function launchRemoteSession(serverId, action, choices, io) {
698
- const { instance, home, launchConfig, runtime, yolo } = choices;
708
+ const { instance, home, launchConfig, harness, yolo } = choices;
699
709
  const choiceArgs = remoteLaunchArgs(choices);
700
710
  const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
701
711
  const remote = requireSessionRemote(route.target, io);
@@ -703,8 +713,8 @@ function launchRemoteSession(serverId, action, choices, io) {
703
713
  throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
704
714
  }
705
715
  if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
706
- if (launchConfig !== undefined || runtime !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
707
- const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
716
+ if (launchConfig !== undefined || harness !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
717
+ const args = ["session", action, "--home", route.home, ...hostHarnessArgs(remote, choiceArgs), "--json"];
708
718
  const { envelope, stderr } = runRemote(route.target, args, io);
709
719
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
710
720
  }
@@ -713,6 +723,15 @@ function launchRemoteSession(serverId, action, choices, io) {
713
723
  * its saved route. Definitions travel on stdin, never as remote file paths or
714
724
  * shell arguments. The host resolves environment references and validates the
715
725
  * definition; this client never substitutes its own environment. */
726
+ /** A launch definition in the host's vocabulary: `harness` for a host with the harness feature,
727
+ * `runtime` for one before 0.27.0. Either spelling is read here; both, disagreeing, are refused. */
728
+ function hostLaunchDefinition(remote, definition) {
729
+ const { harness, runtime, ...rest } = definition; // a disagreeing pair was refused before the probe
730
+ if (runtime !== undefined) noteRuntimeName("runtime in the launch configuration definition (sent as the host's harness key)");
731
+ const value = harness ?? runtime;
732
+ if (value === undefined) return rest;
733
+ return (remote?.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value };
734
+ }
716
735
  export function launchConfigRemote(serverId, options = {}, io = {}) {
717
736
  const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
718
737
  if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
@@ -731,6 +750,7 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
731
750
  if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
732
751
  if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
733
752
  if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
753
+ if (definition.harness !== undefined && definition.runtime !== undefined && definition.harness !== definition.runtime) throw serverError("E_BAD_ARGS", "the definition names harness and runtime (its pre-0.27 name) with different values; nothing was sent");
734
754
  input = Buffer.from(JSON.stringify(definition));
735
755
  args.push("--file", "-");
736
756
  if (keepEnv) args.push("--keep-env");
@@ -751,10 +771,14 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
751
771
  // stdin belongs only to the set operation, never its preceding version probe.
752
772
  const remote = checkRemote(target, { ...io, input: undefined });
753
773
  requireRemoteFeature(remote, target, "launch-config");
754
- const { envelope, stderr } = runRemote(target, [...args, "--json"], { ...io, input });
774
+ // The host's vocabulary: its harness flag, and the definition's key (read either here).
775
+ if (input !== undefined) input = Buffer.from(JSON.stringify(hostLaunchDefinition(remote, definition)));
776
+ const { envelope, stderr } = runRemote(target, [...hostHarnessArgs(remote, args), "--json"], { ...io, input });
755
777
  return { envelope, stderr, target, ...(route ? { route } : {}) };
756
778
  }
757
779
 
780
+ /** The definition keys of a captured (versioned) schedule, removed in 0.26 (as lib/schedule.mjs refuses them). */
781
+ const CAPTURED_SCHEDULE_KEYS = ["definitionVersion", "recurrencePolicy", "execution", "preparation"];
758
782
  /** `oats schedule ...` on the execution host: schedules are host-owned, so
759
783
  * every subcommand runs in the server's registered workspace; refused
760
784
  * before any remote mutation when the remote kernel does not advertise
@@ -767,14 +791,22 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
767
791
  if (["add", "update"].includes(oatsArgs[0])) {
768
792
  const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
769
793
  if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");
770
- let versioned = oatsArgs.includes("--file"); // Cannot classify remote file bytes here.
771
794
  if (indexes.length) {
772
795
  const spec = parseStrictJson(oatsArgs[indexes[0] + 1]);
773
796
  if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw serverError("E_BAD_ARGS", "remote schedule spec must be an object");
774
- versioned ||= ["definitionVersion", "recurrencePolicy", "execution", "preparation"].some(key => Object.hasOwn(spec, key));
775
- if (Array.isArray(spec.argv)) versioned ||= !!capturedSelector(spec.argv.slice(1), {});
797
+ // Captured (versioned) schedules were removed in 0.26: refused here, as a local add
798
+ // refuses them, never forwarded to a remote kernel that might still accept one.
799
+ const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => Object.hasOwn(spec, key));
800
+ if (captured.length) throw serverError("E_SCHEDULE_INVALID", `${captured.join(", ")}: captured schedules are refused (the captured/portable path was removed in 0.26); no request was forwarded`);
801
+ // The host's vocabulary for the spawn job's harness (read either here).
802
+ if (Object.hasOwn(spec, "harness") || Object.hasOwn(spec, "runtime")) {
803
+ const { harness, runtime, ...rest } = spec;
804
+ if (harness !== undefined && runtime !== undefined && harness !== runtime) throw serverError("E_SCHEDULE_INVALID", "the schedule names harness and runtime (its pre-0.27 name) with different values; no request was forwarded");
805
+ const value = harness ?? runtime;
806
+ if (runtime !== undefined) noteRuntimeName("runtime in the schedule spec (sent as the host's harness key)");
807
+ oatsArgs = oatsArgs.map((a, i) => (i === indexes[0] + 1 ? JSON.stringify((remote.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value }) : a));
808
+ }
776
809
  }
777
- if (versioned && remote.scheduleApi !== 2) throw serverError("E_REMOTE_INCOMPATIBLE", "captured schedule mutation requires advertised scheduleApi 2; no request was forwarded");
778
810
  }
779
811
  const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
780
812
  const { envelope, stderr } = runRemote(target, args, io);
@@ -1,7 +1,8 @@
1
- /** Small shape checks shared by portable document/record codecs. Call only on
2
- * decoded or canonical-data-validated values; these helpers do not resolve policy. */
1
+ /** Small shape checks for decoded documents (capability manifests' binding
2
+ * declarations). Call only on decoded or canonical-data-validated values; these
3
+ * helpers do not resolve policy. */
3
4
  import { oatsError } from "./errors.mjs";
4
- import { scalarString } from "./portable-values.mjs";
5
+ import { scalarString } from "./canonical-json.mjs";
5
6
 
6
7
  export const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
7
8
  export function invalidShape(pointer, message, code = "invalid-declaration") {
@@ -0,0 +1,44 @@
1
+ /** Tree mechanics only: the kernel's catchable recursive copy (spawn, retire and
2
+ * work recovery). No acquisition, selection, trust, lock or lifecycle policy. */
3
+ import {
4
+ chmodSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync,
5
+ } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { oatsError } from "./errors.mjs";
8
+
9
+ /** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
10
+ * except catchably.
11
+ *
12
+ * Node 22's recursive `cpSync` performs its recursion in native code, and on
13
+ * macOS an unreadable directory inside the tree surfaces as an uncaught libc++
14
+ * `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
15
+ * runs, so a transaction using it can never clean up staging or roll back the
16
+ * store, the lock and the ignore file. Every package-, capability- and
17
+ * user-shaped tree in the engine therefore goes through this hand-walk instead,
18
+ * where an EACCES is an ordinary throwable error.
19
+ *
20
+ * Semantics chosen to be safe rather than maximally faithful:
21
+ * - deterministic traversal (sorted entries), so two copies of one tree hash
22
+ * identically;
23
+ * - symlinks are recreated VERBATIM — never followed, never rewritten — because
24
+ * the bytes about to be hashed must be the bytes the author wrote;
25
+ * - FIFOs, sockets and device nodes are rejected fail-closed: they are not
26
+ * distributable content, and copying them has no defined meaning here;
27
+ * - directory modes are applied AFTER their children, so a read-only source
28
+ * directory cannot block writing its own contents. */
29
+ export function copyTreeSafe(src, dest) {
30
+ const st = lstatSync(src);
31
+ if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
32
+ if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
33
+ if (!st.isDirectory()) {
34
+ throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
35
+ }
36
+ mkdirSync(dest, { recursive: true });
37
+ for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
38
+ copyTreeSafe(join(src, e.name), join(dest, e.name));
39
+ }
40
+ chmodSync(dest, st.mode & 0o7777);
41
+ }
42
+
43
+
44
+
package/lib/workspace.mjs CHANGED
@@ -16,6 +16,7 @@ import { parseConfigData } from "./config-data.mjs";
16
16
  import { oatsError } from "./errors.mjs";
17
17
  import * as defaultRemote from "./remote.mjs";
18
18
  import { bindRemote, classifyPackageValue } from "./packages.mjs";
19
+ import { manifestContractProblems } from "./capability-contract.mjs";
19
20
 
20
21
  /* ───────────────────────────── errors ─────────────────────────────────── */
21
22
 
@@ -293,6 +294,13 @@ function fromProblems(remote, capabilities, path, problems, { here }) {
293
294
  export function validateLocal(value) {
294
295
  const problems = validateAgainst(schemaFor("local"), value);
295
296
  if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
297
+ // runtime → harness (0.27.0, lead call 6): `runtime`, the pre-0.27 name, is still read; both,
298
+ // disagreeing, are refused.
299
+ if (isObject(value) && isObject(value["launch-configs"])) for (const [name, entry] of Object.entries(value["launch-configs"])) {
300
+ if (isObject(entry) && Object.hasOwn(entry, "runtime") && Object.hasOwn(entry, "harness") && entry.runtime !== entry.harness) {
301
+ problems.push({ path: `/launch-configs/${pointerKey(name)}/runtime`, reason: "harness-conflict", message: `harness ${show(entry.harness)} and runtime ${show(entry.runtime)} disagree: \`runtime\` is the pre-0.27 name of \`harness\` — keep one` });
302
+ }
303
+ }
296
304
  return problems;
297
305
  }
298
306
 
@@ -335,14 +343,24 @@ function schemaError(kind, origin, problems, value) {
335
343
 
336
344
  /* ───────────────────────────── public API ─────────────────────────────── */
337
345
 
338
- /** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING. */
346
+ /** What a 0.25 `oats-config.yaml` held, and where each piece lives now (0.26.0 removed the file). */
347
+ export const LEGACY_CONFIG_MIGRATION = "oats-config.yaml is no longer read (removed in 0.26.0): launch-configs moved to the deployment's oats-local.yaml; "
348
+ + "capability bindings, layers and team moved to oats-workspace.yaml (defaults, teams) and each soul's soul.yaml in its member repository; "
349
+ + "agents-md-injection, skill-overrides, work-modes and yolo are removed (yolo is --yolo or a launch configuration's yolo). Move what you still need, then delete the file";
350
+
351
+ /** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING.
352
+ * A 0.25 `oats-config.yaml` between `dir` and the deployment (inclusive) is refused
353
+ * (E_CONFIG_BROKEN, naming the migration): nothing reads it, so it must not look
354
+ * like configuration. Above the deployment is not this deployment's business. */
339
355
  export function loadLocal(dir) {
340
356
  let current = resolve(dir);
341
- const visited = [];
357
+ const visited = [], legacy = [];
342
358
  for (;;) {
343
359
  const candidate = join(current, "oats-local.yaml");
344
360
  visited.push(candidate);
361
+ if (existsSync(join(current, "oats-config.yaml"))) legacy.push(join(current, "oats-config.yaml"));
345
362
  if (existsSync(candidate)) {
363
+ if (legacy.length) throw fail("E_CONFIG_BROKEN", `${legacy.join(", ")}: ${LEGACY_CONFIG_MIGRATION}`, { dir: resolve(dir), deployment: current, files: legacy, reason: "legacy-config" });
346
364
  const origin = { kind: "local", path: candidate };
347
365
  const read = readDeclaration("local", readFileSync(candidate), origin);
348
366
  if (read.problems) throw schemaError("local", origin, read.problems, read.value);
@@ -352,7 +370,7 @@ export function loadLocal(dir) {
352
370
  if (parent === current) break;
353
371
  current = parent;
354
372
  }
355
- throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}`, { dir: resolve(dir), searched: visited });
373
+ throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}${legacy.length ? ` (${legacy[0]} is a 0.25 deployment's configuration, which 0.26.0 no longer reads: retire its instances with 0.25, then \`oats onboard\` it)` : ""}`, { dir: resolve(dir), searched: visited, ...(legacy.length ? { legacy } : {}) });
356
374
  }
357
375
 
358
376
  /** Observe the workspace host: → { key, url, commit, workspace, observedAt }. */
@@ -374,6 +392,47 @@ export async function observeWorkspace(ref, { at, remote: injected, remoteOption
374
392
  return { key: obs.key, url: obs.url, ref, commit: obs.commit, workspace: read.value, observedAt: obs.observedAt };
375
393
  }
376
394
 
395
+ /**
396
+ * A soul's team labels as its repository declares them NOW, without a workspace discovery (teams
397
+ * contract decision 6, the live teams of an existing home): the workspace host has already been read
398
+ * (`wsObs`, observeWorkspace); this reads the soul's own repo once — a member at its current commit
399
+ * (`souls/<name>/soul.yaml`, and `oats-membership.yaml` only when the soul declares no `team`), or an
400
+ * `external[]` soul at its pinned commit (its entry's `team` wins, as in discovery).
401
+ * → labels (primary first), or null when the workspace no longer lists the soul's repo.
402
+ * Membership is not re-confirmed here: the home's soul was confirmed at spawn; this reads labels only.
403
+ */
404
+ export async function observeSoulLabels(wsObs, { name, repoKey }, { remote: injected, remoteOptions } = {}) {
405
+ const remote = remoteOf({ remote: injected, remoteOptions });
406
+ const workspace = wsObs.workspace;
407
+ const readSoul = async (ref, commit, path) => {
408
+ const file = `${path.replace(/\/+$/, "")}/soul.yaml`;
409
+ const { bytes } = await remote.readRemoteFile(ref, commit, file);
410
+ const read = readDeclaration("soul", bytes, { kind: "soul", repoKey, commit, path: file });
411
+ if (read.problems) throw schemaError("soul", { kind: "soul", repoKey, commit, path: file }, read.problems, read.value);
412
+ return read.value;
413
+ };
414
+ for (const entry of workspace.external || []) {
415
+ const pin = entry.source.lastIndexOf("@");
416
+ let key; try { key = refKey(remote, entry.source.slice(0, pin)); } catch { continue; }
417
+ if (key !== repoKey) continue;
418
+ if (typeof entry.team === "string") return [entry.team];
419
+ const soul = await readSoul(entry.source.slice(0, pin), entry.source.slice(pin + 1), entry.soul);
420
+ if (soul.name !== name) continue;
421
+ return labelList(soul.team) ?? [];
422
+ }
423
+ const memberRef = (workspace.members || []).find((ref) => { try { return refKey(remote, ref) === repoKey; } catch { return false; } });
424
+ if (!memberRef) return null;
425
+ const obs = await remote.observeRemote(memberRef);
426
+ const soul = await readSoul(memberRef, obs.commit, `souls/${name}`);
427
+ const own = labelList(soul.team);
428
+ if (own) return own;
429
+ try {
430
+ const { bytes } = await remote.readRemoteFile(memberRef, obs.commit, "oats-membership.yaml");
431
+ const read = readDeclaration("membership", bytes, { kind: "membership", repoKey, commit: obs.commit, path: "oats-membership.yaml" });
432
+ return read.problems ? [] : labelList(read.value.team) ?? [];
433
+ } catch (e) { if (e?.code === "E_REMOTE_PATH_MISSING") return []; throw e; }
434
+ }
435
+
377
436
  const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: false, reason, detail, ...extra });
378
437
 
379
438
  /**
@@ -422,21 +481,28 @@ export async function confirmMembership(workspaceObs, memberRef, { remote: injec
422
481
  const caseOnly = backlinkKey.toLowerCase() === workspaceObs.key.toLowerCase();
423
482
  return unconfirmed(key, "backlink-elsewhere", `${key}@${obs.commit.slice(0, 12)} names workspace ${backlinkKey}, not ${workspaceObs.key}${caseOnly ? " (the keys differ only by letter case: repo paths are case-sensitive identities; spell the workspace ref exactly as the workspace lists itself)" : ""}`, { commit: obs.commit, backlink: backlinkKey, ...(caseOnly ? { caseOnly: true } : {}) });
424
483
  }
425
- return { key, commit: obs.commit, confirmed: true, team: read.value.team ?? null };
484
+ const labels = labelList(read.value.team) ?? [];
485
+ return { key, commit: obs.commit, confirmed: true, team: labels[0] ?? null, labels };
426
486
  }
427
487
 
428
488
  /* ───────────────────────────── enumeration ────────────────────────────── */
429
489
 
430
490
  const SOUL_FILE = /^([^/]+)\/soul\.yaml$/;
431
491
  const CAP_FILE = /^([^/]+)\/oats\.json$/;
432
- const teamOf = (item, fallback) => (typeof item?.team === "string" ? item.team : fallback ?? null);
492
+ /** A declared `team` as a label list (teams contract 2026-09-25: a label or a non-empty list of distinct
493
+ * labels, the first the primary); null when the item declares none. */
494
+ const labelList = (team) => (typeof team === "string" ? [team] : Array.isArray(team) ? team.filter((l) => typeof l === "string") : null);
495
+ /** The item's labels, else the repo default's (oats-membership.yaml). */
496
+ const labelsOf = (item, fallback) => labelList(item?.team) ?? [...(fallback || [])];
497
+ /** Each label with the path it is declared at: `/team` for one label, `/team/<i>` in a list. */
498
+ const labelPaths = (item, labels, file) => labels.map((label, i) => [label, Array.isArray(item?.team) ? `${file}#/team/${i}` : `${file}#/team`]);
433
499
 
434
500
  /**
435
501
  * Enumerate souls/*\/soul.yaml and capabilities/*\/oats.json of one repo at one commit.
436
502
  * Validates each item; collects problems instead of aborting.
437
503
  * → { souls: [SoulEntry], capabilities: [CapEntry], problems: [{ code, path, message, repoKey }] }
438
504
  */
439
- async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, teams = null } = {}) {
505
+ async function enumerateRepo(remote, ref, key, commit, { defaultLabels = [], teams = null } = {}) {
440
506
  const souls = [];
441
507
  const capabilities = [];
442
508
  const problems = [];
@@ -467,9 +533,10 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
467
533
  if (definition.name !== m[1]) problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} does not match its directory ${show(m[1])}`);
468
534
  if (soulNames.has(definition.name)) { problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} is already declared by ${soulNames.get(definition.name)}; the second declaration is not listed`); continue; }
469
535
  soulNames.set(definition.name, file);
470
- const team = teamOf(definition, defaultTeam);
471
- checkTeam(team, `${file}#/team`);
472
- souls.push({ name: definition.name, path, repoKey: key, commit, team, private: definition.private === true, definition });
536
+ const labels = labelsOf(definition, defaultLabels);
537
+ for (const [label, at] of labelPaths(definition, labels, file)) checkTeam(label, at);
538
+ // Souls have no private mode since 0.26.0: `private` is accepted, warned (soul-private-ignored) and never hides a soul.
539
+ souls.push({ name: definition.name, path, repoKey: key, commit, team: labels[0] ?? null, labels, private: false, definition });
473
540
  }
474
541
  const capNames = new Map();
475
542
  for (const entry of await list("capabilities")) {
@@ -497,9 +564,13 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
497
564
  },
498
565
  }, manifest);
499
566
  if (shape.length) { for (const p of shape) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, p.message); continue; }
567
+ // The kernel contract (launch environment, hooks): a manifest the kernel could not run is not listed.
568
+ const contract = manifestContractProblems(manifest);
569
+ if (contract.length) { for (const p of contract) problem("E_WORKSPACE_SCHEMA", `${file}#${p.pointer}`, p.message); continue; }
500
570
  if (capNames.has(manifest.capability)) { problem("E_WORKSPACE_SCHEMA", `${file}#/capability`, `capability ${show(manifest.capability)} is already declared by ${capNames.get(manifest.capability)}; the second declaration is not listed`); continue; }
501
571
  capNames.set(manifest.capability, file);
502
- const team = teamOf(manifest, defaultTeam);
572
+ // A capability is LISTED under one team (its own `team:`, else the repo default's primary); it joins none.
573
+ const team = typeof manifest.team === "string" ? manifest.team : defaultLabels[0] ?? null;
503
574
  checkTeam(team, `${file}#/team`);
504
575
  capabilities.push({ name: manifest.capability, path, repoKey: key, commit, team, private: manifest.private === true, manifest });
505
576
  }
@@ -537,7 +608,7 @@ export async function discoverRepo(ref, { at, remote: injected, remoteOptions }
537
608
  } catch (e) {
538
609
  if (e?.code !== "E_REMOTE_PATH_MISSING") throw e;
539
610
  }
540
- const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultTeam: membership?.team ?? null, teams: null });
611
+ const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultLabels: labelList(membership?.team) ?? [], teams: null });
541
612
  return { key: obs.key, commit: obs.commit, membership, ...items, problems: [...problems, ...items.problems] };
542
613
  }
543
614
 
@@ -557,7 +628,7 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
557
628
  const members = [];
558
629
  for (const memberRef of workspace.members || []) {
559
630
  const confirmation = await confirmMembership(wsObs, memberRef, { remote });
560
- const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, souls: [], capabilities: [], publishes: null };
631
+ const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, labels: confirmation.labels ?? [], souls: [], capabilities: [], publishes: null };
561
632
  if (!confirmation.confirmed) {
562
633
  // Contract: an unconfirmed member contributes nothing but its row.
563
634
  row.reason = confirmation.reason;
@@ -565,8 +636,10 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
565
636
  members.push(row);
566
637
  continue;
567
638
  }
568
- if (row.team !== null && !teams.includes(row.team)) problems.push({ code: "E_TEAM_UNKNOWN", repoKey: row.key, path: "oats-membership.yaml#/team", message: `team ${show(row.team)} is not declared in the workspace's teams (${teams.join(", ") || "none"})` });
569
- const items = await enumerateRepo(remote, memberRef, row.key, row.commit, { defaultTeam: row.team, teams });
639
+ for (const [i, label] of row.labels.entries()) {
640
+ if (!teams.includes(label)) problems.push({ code: "E_TEAM_UNKNOWN", repoKey: row.key, path: row.labels.length > 1 ? `oats-membership.yaml#/team/${i}` : "oats-membership.yaml#/team", message: `team ${show(label)} is not declared in the workspace's teams (${teams.join(", ") || "none"})` });
641
+ }
642
+ const items = await enumerateRepo(remote, memberRef, row.key, row.commit, { defaultLabels: row.labels, teams });
570
643
  row.souls = items.souls;
571
644
  row.capabilities = items.capabilities;
572
645
  row.publishes = items.publishes;
@@ -593,11 +666,50 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
593
666
  }
594
667
  const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
595
668
  if (read.problems) { for (const p of read.problems) pushProblem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
596
- const team = teamOf(entry, null) ?? teamOf(read.value, null);
597
- if (team !== null && !teams.includes(team)) pushProblem("E_TEAM_UNKNOWN", `${file}#/team`, `team ${show(team)} is not declared in the workspace's teams`);
598
- externalRows.push({ source: entry.source, key, commit, soul: { name: read.value.name, path: entry.soul, repoKey: key, commit, team, private: read.value.private === true, definition: read.value } });
669
+ // The workspace's `external[].team` (one label) overrides the soul's own labels.
670
+ const labels = labelList(entry.team) ?? labelList(read.value.team) ?? [];
671
+ for (const label of labels) if (!teams.includes(label)) pushProblem("E_TEAM_UNKNOWN", `${file}#/team`, `team ${show(label)} is not declared in the workspace's teams`);
672
+ externalRows.push({ source: entry.source, key, commit, soul: { name: read.value.name, path: entry.soul, repoKey: key, commit, team: labels[0] ?? null, labels, private: false, definition: read.value } });
599
673
  }
600
- return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems };
674
+ return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems, warnings: [...unmappedLabelWarnings(workspace, members, externalRows, teams), ...privateSoulWarnings([...members.filter((m) => m.confirmed).flatMap((m) => m.souls), ...externalRows.map((e) => e.soul)])] };
675
+ }
676
+
677
+ /** Teams contract decision 5: a soul label the workspace declares in `teams:` but does not map in
678
+ * `messaging.byTeam` is a WARNING, not a problem — the label stays eligible-but-unmapped and the
679
+ * messaging provider falls back to the personal team for it. (An undeclared label is E_TEAM_UNKNOWN.)
680
+ * ONE warning per unmapped label, naming its souls (a personal-only workspace would otherwise print
681
+ * a line per soul): { code, label, souls, paths, message }, sorted by label. */
682
+ const byCodepointOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
683
+ function unmappedLabelWarnings(workspace, members, externalRows, teams) {
684
+ const byTeam = isObject(workspace?.messaging) && isObject(workspace.messaging.byTeam) ? workspace.messaging.byTeam : {};
685
+ const byLabel = new Map();
686
+ const souls = [...members.filter((m) => m.confirmed).flatMap((m) => m.souls), ...externalRows.map((e) => e.soul)];
687
+ for (const soul of souls) {
688
+ for (const label of soul.labels || []) {
689
+ if (!teams.includes(label) || Object.hasOwn(byTeam, label)) continue;
690
+ const row = byLabel.get(label) || { souls: [], paths: [] };
691
+ row.souls.push(soul.name); row.paths.push(`${soul.repoKey}:${soul.path}/soul.yaml#/team`);
692
+ byLabel.set(label, row);
693
+ }
694
+ }
695
+ return [...byLabel.keys()].sort(byCodepointOrder).map((label) => {
696
+ const { souls: names, paths } = byLabel.get(label);
697
+ const order = names.map((n, i) => i).sort((x, y) => byCodepointOrder(names[x], names[y]) || byCodepointOrder(paths[x], paths[y]));
698
+ const sorted = order.map((i) => names[i]);
699
+ return { code: "unmapped-team-label", label, souls: sorted, paths: order.map((i) => paths[i]),
700
+ message: `team ${show(label)} has no messaging.byTeam entry; its souls (${sorted.join(", ")}) fall back to the personal team for it` };
701
+ });
702
+ }
703
+
704
+ /** `private:` in a soul.yaml has no effect since 0.26.0 (human decision 2026-09-25): every soul of a
705
+ * confirmed member, and every external soul, is listed and spawnable. The field is still ACCEPTED
706
+ * (existing files keep validating) and named by ONE warning per soul that carries it:
707
+ * { code: "soul-private-ignored", soul, repoKey, path, message }, sorted by soul name, then path. */
708
+ function privateSoulWarnings(souls) {
709
+ return souls.filter((s) => s.definition && Object.hasOwn(s.definition, "private"))
710
+ .map((s) => ({ code: "soul-private-ignored", soul: s.name, repoKey: s.repoKey, path: `${s.repoKey}:${s.path}/soul.yaml#/private`,
711
+ message: `\`private\` has no effect on a soul since 0.26.0; remove it from ${s.path}/soul.yaml` }))
712
+ .sort((a, b) => byCodepointOrder(a.soul, b.soul) || byCodepointOrder(a.path, b.path));
601
713
  }
602
714
 
603
715
  /**
@@ -648,7 +760,7 @@ export function standaloneRepo(ref, commit, discovery, { remote: injected } = {}
648
760
  });
649
761
  return {
650
762
  standalone: true, key, commit: source.commit ?? commit ?? null, workspace: null,
651
- members: [{ key, commit: source.commit ?? commit ?? null, confirmed: false, reason: "cannot-read", detail: `workspace of ${key} cannot be read; standalone view`, team: source.team ?? source.membership?.team ?? null, souls, capabilities, publishes: source.publishes ?? null }],
652
- external: [], problems,
763
+ members: [{ key, commit: source.commit ?? commit ?? null, confirmed: false, reason: "cannot-read", detail: `workspace of ${key} cannot be read; standalone view`, team: source.team ?? labelList(source.membership?.team)?.[0] ?? null, souls, capabilities, publishes: source.publishes ?? null }],
764
+ external: [], problems, warnings: privateSoulWarnings(souls),
653
765
  };
654
766
  }