@awebai/oats 0.25.9 → 0.26.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 (177) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  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 +218 -47
  21. package/docs/capability-manifest.schema.json +13 -4
  22. package/docs/configuration.md +17 -5
  23. package/docs/conventions.md +16 -26
  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 +537 -261
  39. package/docs/desktop-instance-start.md +1 -1
  40. package/docs/desktop.md +7 -13
  41. package/docs/execution-targets.md +16 -18
  42. package/docs/first-team.md +14 -17
  43. package/docs/implementation.md +28 -59
  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 +29 -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 +75 -52
  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/schedules.md +48 -126
  60. package/docs/soul.schema.json +11 -4
  61. package/docs/souls-and-instances.md +56 -43
  62. package/docs/workspaces.md +80 -58
  63. package/injects/instance-boundary.md +1 -1
  64. package/injects/work-attached.md +1 -1
  65. package/injects/work-workspace.md +2 -2
  66. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  67. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  68. package/lib/capability-contract.mjs +110 -0
  69. package/lib/config-data.mjs +2 -2
  70. package/lib/core.mjs +700 -4824
  71. package/lib/digest.mjs +12 -0
  72. package/lib/instance-inspect.mjs +396 -0
  73. package/lib/instance-lifecycle.mjs +3 -4
  74. package/lib/instance-resolution.mjs +212 -26
  75. package/lib/instruction-composition.mjs +0 -20
  76. package/lib/materialize.mjs +6 -4
  77. package/lib/operator-dispatch.mjs +33 -13
  78. package/lib/packages.mjs +25 -190
  79. package/lib/provider-binding.mjs +4 -2
  80. package/lib/provider-reasons.mjs +3 -68
  81. package/lib/resolve.mjs +204 -68
  82. package/lib/schedule.mjs +97 -272
  83. package/lib/servers.mjs +13 -13
  84. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  85. package/lib/tree-copy.mjs +44 -0
  86. package/lib/workspace.mjs +125 -20
  87. package/package-catalog.json +6 -6
  88. package/package.json +1 -1
  89. package/skills/integration-authoring/SKILL.md +48 -40
  90. package/skills/oats-getting-started/SKILL.md +105 -110
  91. package/skills/oats-support/SKILL.md +2 -2
  92. package/skills/soul-craft/SKILL.md +13 -6
  93. package/bin/oats-pi-sdk-host.mjs +0 -17
  94. package/docs/2026-09-03-architecture-proposal.md +0 -642
  95. package/docs/artifact-approvals.schema.json +0 -7
  96. package/docs/captured-invocation-context.schema.json +0 -7
  97. package/docs/captured-resolution.schema.json +0 -7
  98. package/docs/design/package-engine-contract.md +0 -813
  99. package/docs/design/package-runtime-api.md +0 -588
  100. package/docs/desktop-succession.md +0 -57
  101. package/docs/execution-capsule.schema.json +0 -108
  102. package/docs/first-team-demo.md +0 -92
  103. package/docs/knowledge-migration.md +0 -147
  104. package/docs/migration-from-oas.md +0 -103
  105. package/docs/oats-config.schema.json +0 -172
  106. package/docs/oats-lock-v3.schema.json +0 -7
  107. package/docs/oats-lock.schema.json +0 -175
  108. package/docs/operating-team-migration.md +0 -470
  109. package/docs/portable.schema.json +0 -2512
  110. package/docs/provider-check-input.schema.json +0 -7
  111. package/docs/rebuild-to-v2.md +0 -511
  112. package/docs/workspace-adoption.md +0 -74
  113. package/injects/framework-workspace.md +0 -7
  114. package/injects/local-soul.md +0 -19
  115. package/injects/oats-portable.md +0 -20
  116. package/injects/oats.md +0 -11
  117. package/injects/portable-instance-boundary.md +0 -39
  118. package/injects/portable-work-directory.md +0 -29
  119. package/lib/artifact-approvals.mjs +0 -120
  120. package/lib/artifact-tree.mjs +0 -141
  121. package/lib/capability-artifacts.mjs +0 -179
  122. package/lib/capability-execution.mjs +0 -15
  123. package/lib/capability-inputs.mjs +0 -39
  124. package/lib/capability-provenance.mjs +0 -231
  125. package/lib/captured-action-shape.mjs +0 -21
  126. package/lib/captured-admission-shape.mjs +0 -20
  127. package/lib/captured-binding-file.mjs +0 -36
  128. package/lib/captured-dispatch.mjs +0 -66
  129. package/lib/captured-instance-index.mjs +0 -277
  130. package/lib/captured-invocation-context.mjs +0 -130
  131. package/lib/captured-launch-request.mjs +0 -66
  132. package/lib/captured-operation-process.mjs +0 -15
  133. package/lib/captured-pi-custody.mjs +0 -29
  134. package/lib/captured-pi-host.mjs +0 -167
  135. package/lib/captured-pi-outcome.mjs +0 -172
  136. package/lib/captured-resolutions.mjs +0 -275
  137. package/lib/captured-scaffold.mjs +0 -87
  138. package/lib/captured-selector.mjs +0 -28
  139. package/lib/captured-session-backend.mjs +0 -52
  140. package/lib/captured-source-receipt-file.mjs +0 -72
  141. package/lib/helper-injection-policy.mjs +0 -104
  142. package/lib/legacy-lock-codec.mjs +0 -106
  143. package/lib/manifest-settings.mjs +0 -84
  144. package/lib/package-closure.mjs +0 -48
  145. package/lib/package-materialization.mjs +0 -83
  146. package/lib/pi-sdk-host.mjs +0 -229
  147. package/lib/portable-artifacts.mjs +0 -115
  148. package/lib/portable-choices.mjs +0 -82
  149. package/lib/portable-composition.mjs +0 -136
  150. package/lib/portable-digest.mjs +0 -105
  151. package/lib/portable-identity.mjs +0 -40
  152. package/lib/portable-lock.mjs +0 -117
  153. package/lib/portable-onboarding-request.mjs +0 -49
  154. package/lib/portable-onboarding.mjs +0 -256
  155. package/lib/portable-package-preparation.mjs +0 -188
  156. package/lib/portable-policy.mjs +0 -44
  157. package/lib/portable-soul.mjs +0 -42
  158. package/lib/portable-state.mjs +0 -80
  159. package/lib/prepare-composition.mjs +0 -170
  160. package/lib/prepared-bindings.mjs +0 -92
  161. package/lib/prepared-resources.mjs +0 -127
  162. package/lib/provider-binding-broker.mjs +0 -65
  163. package/lib/provider-binding-wire.mjs +0 -116
  164. package/lib/readiness.mjs +0 -225
  165. package/lib/repository-observation.mjs +0 -226
  166. package/lib/resolution-shape.mjs +0 -393
  167. package/lib/schedule-capsule.mjs +0 -206
  168. package/lib/soul-constraints.mjs +0 -40
  169. package/lib/source-projection.mjs +0 -84
  170. package/lib/source-spec.mjs +0 -189
  171. package/lib/workspace-definition.mjs +0 -126
  172. package/lib/workspace-discovery.mjs +0 -146
  173. package/skills/oats/SKILL.md +0 -162
  174. package/skills/oats-config/SKILL.md +0 -164
  175. package/skills/oats-packages/SKILL.md +0 -184
  176. package/skills/oats-portable/SKILL.md +0 -115
  177. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/servers.mjs CHANGED
@@ -21,8 +21,7 @@ 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";
26
25
 
27
26
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
28
27
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -234,7 +233,7 @@ export function checkRemote(target, io = {}) {
234
233
  launchOptions: list("launchOptions", []),
235
234
  remote: list("remote", []),
236
235
  features: list("features", []),
237
- operationsApi: probe.operationsApi === 1 ? 1 : null,
236
+ operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
238
237
  scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
239
238
  advertised: Array.isArray(probe.runtimes),
240
239
  };
@@ -407,8 +406,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
407
406
  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
407
  // The remote roster: the soul's runtime default for the support check,
409
408
  // 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).
409
+ // does not carry it, and guessing it from the workspace would be wrong).
412
410
  const status = runRemote(target, json(withScope(["status"])), io).envelope;
413
411
  if (!status.ok) return { envelope: status, stderr: "" };
414
412
  checkRemoteSupport(remote, target, oatsArgs, status.result);
@@ -486,7 +484,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
486
484
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
487
485
  }
488
486
  if (OPERATIONS_COMMANDS.has(cmd)) {
489
- // The operations contract (inspect, operation run, use, soul set): the
487
+ // The operations contract (inspect, operation run): the
490
488
  // remote must advertise it before anything is sent. An explicit --dir is
491
489
  // the exact member context the caller chose and travels as is; without
492
490
  // one, a --home selection is left to the host (the home is its own
@@ -505,14 +503,14 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
505
503
  if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
506
504
  }
507
505
  const remote = checkRemote(target, io);
508
- if (!Array.isArray(remote.features) || !remote.features.includes("operations") || remote.operationsApi !== 1) {
506
+ if (!Array.isArray(remote.features) || !remote.features.includes("operations") || ![1, 2].includes(remote.operationsApi)) {
509
507
  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
508
  }
511
509
  const scoped = args.includes("--dir") || args.includes("--home") ? args : [...args, "--dir", target.workspace];
512
510
  const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
513
511
  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
512
  }
515
- throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect, operation, use and soul only (not ${cmd})`);
513
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect and operation only (not ${cmd})`);
516
514
  }
517
515
 
518
516
  // ------------------------------------------------------------------- roster
@@ -656,7 +654,7 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
656
654
  export const SESSION_START_REMOTE_VERSION = "0.22.9";
657
655
  /** The kernel version whose probe first advertises the operations contract. */
658
656
  export const OPERATIONS_REMOTE_VERSION = "0.22.16";
659
- const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
657
+ const OPERATIONS_COMMANDS = new Set(["inspect", "operation"]);
660
658
 
661
659
  /** `session start` on the execution host for a remote instance: the same
662
660
  * route resolution as inspect, refused before any mutation when the remote
@@ -755,6 +753,8 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
755
753
  return { envelope, stderr, target, ...(route ? { route } : {}) };
756
754
  }
757
755
 
756
+ /** The definition keys of a captured (versioned) schedule, removed in 0.26 (as lib/schedule.mjs refuses them). */
757
+ const CAPTURED_SCHEDULE_KEYS = ["definitionVersion", "recurrencePolicy", "execution", "preparation"];
758
758
  /** `oats schedule ...` on the execution host: schedules are host-owned, so
759
759
  * every subcommand runs in the server's registered workspace; refused
760
760
  * before any remote mutation when the remote kernel does not advertise
@@ -767,14 +767,14 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
767
767
  if (["add", "update"].includes(oatsArgs[0])) {
768
768
  const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
769
769
  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
770
  if (indexes.length) {
772
771
  const spec = parseStrictJson(oatsArgs[indexes[0] + 1]);
773
772
  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), {});
773
+ // Captured (versioned) schedules were removed in 0.26: refused here, as a local add
774
+ // refuses them, never forwarded to a remote kernel that might still accept one.
775
+ const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => Object.hasOwn(spec, key));
776
+ 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`);
776
777
  }
777
- if (versioned && remote.scheduleApi !== 2) throw serverError("E_REMOTE_INCOMPATIBLE", "captured schedule mutation requires advertised scheduleApi 2; no request was forwarded");
778
778
  }
779
779
  const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
780
780
  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
 
@@ -335,14 +336,24 @@ function schemaError(kind, origin, problems, value) {
335
336
 
336
337
  /* ───────────────────────────── public API ─────────────────────────────── */
337
338
 
338
- /** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING. */
339
+ /** What a 0.25 `oats-config.yaml` held, and where each piece lives now (0.26.0 removed the file). */
340
+ 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; "
341
+ + "capability bindings, layers and team moved to oats-workspace.yaml (defaults, teams) and each soul's soul.yaml in its member repository; "
342
+ + "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";
343
+
344
+ /** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING.
345
+ * A 0.25 `oats-config.yaml` between `dir` and the deployment (inclusive) is refused
346
+ * (E_CONFIG_BROKEN, naming the migration): nothing reads it, so it must not look
347
+ * like configuration. Above the deployment is not this deployment's business. */
339
348
  export function loadLocal(dir) {
340
349
  let current = resolve(dir);
341
- const visited = [];
350
+ const visited = [], legacy = [];
342
351
  for (;;) {
343
352
  const candidate = join(current, "oats-local.yaml");
344
353
  visited.push(candidate);
354
+ if (existsSync(join(current, "oats-config.yaml"))) legacy.push(join(current, "oats-config.yaml"));
345
355
  if (existsSync(candidate)) {
356
+ if (legacy.length) throw fail("E_CONFIG_BROKEN", `${legacy.join(", ")}: ${LEGACY_CONFIG_MIGRATION}`, { dir: resolve(dir), deployment: current, files: legacy, reason: "legacy-config" });
346
357
  const origin = { kind: "local", path: candidate };
347
358
  const read = readDeclaration("local", readFileSync(candidate), origin);
348
359
  if (read.problems) throw schemaError("local", origin, read.problems, read.value);
@@ -352,7 +363,7 @@ export function loadLocal(dir) {
352
363
  if (parent === current) break;
353
364
  current = parent;
354
365
  }
355
- throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}`, { dir: resolve(dir), searched: visited });
366
+ 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
367
  }
357
368
 
358
369
  /** Observe the workspace host: → { key, url, commit, workspace, observedAt }. */
@@ -374,6 +385,47 @@ export async function observeWorkspace(ref, { at, remote: injected, remoteOption
374
385
  return { key: obs.key, url: obs.url, ref, commit: obs.commit, workspace: read.value, observedAt: obs.observedAt };
375
386
  }
376
387
 
388
+ /**
389
+ * A soul's team labels as its repository declares them NOW, without a workspace discovery (teams
390
+ * contract decision 6, the live teams of an existing home): the workspace host has already been read
391
+ * (`wsObs`, observeWorkspace); this reads the soul's own repo once — a member at its current commit
392
+ * (`souls/<name>/soul.yaml`, and `oats-membership.yaml` only when the soul declares no `team`), or an
393
+ * `external[]` soul at its pinned commit (its entry's `team` wins, as in discovery).
394
+ * → labels (primary first), or null when the workspace no longer lists the soul's repo.
395
+ * Membership is not re-confirmed here: the home's soul was confirmed at spawn; this reads labels only.
396
+ */
397
+ export async function observeSoulLabels(wsObs, { name, repoKey }, { remote: injected, remoteOptions } = {}) {
398
+ const remote = remoteOf({ remote: injected, remoteOptions });
399
+ const workspace = wsObs.workspace;
400
+ const readSoul = async (ref, commit, path) => {
401
+ const file = `${path.replace(/\/+$/, "")}/soul.yaml`;
402
+ const { bytes } = await remote.readRemoteFile(ref, commit, file);
403
+ const read = readDeclaration("soul", bytes, { kind: "soul", repoKey, commit, path: file });
404
+ if (read.problems) throw schemaError("soul", { kind: "soul", repoKey, commit, path: file }, read.problems, read.value);
405
+ return read.value;
406
+ };
407
+ for (const entry of workspace.external || []) {
408
+ const pin = entry.source.lastIndexOf("@");
409
+ let key; try { key = refKey(remote, entry.source.slice(0, pin)); } catch { continue; }
410
+ if (key !== repoKey) continue;
411
+ if (typeof entry.team === "string") return [entry.team];
412
+ const soul = await readSoul(entry.source.slice(0, pin), entry.source.slice(pin + 1), entry.soul);
413
+ if (soul.name !== name) continue;
414
+ return labelList(soul.team) ?? [];
415
+ }
416
+ const memberRef = (workspace.members || []).find((ref) => { try { return refKey(remote, ref) === repoKey; } catch { return false; } });
417
+ if (!memberRef) return null;
418
+ const obs = await remote.observeRemote(memberRef);
419
+ const soul = await readSoul(memberRef, obs.commit, `souls/${name}`);
420
+ const own = labelList(soul.team);
421
+ if (own) return own;
422
+ try {
423
+ const { bytes } = await remote.readRemoteFile(memberRef, obs.commit, "oats-membership.yaml");
424
+ const read = readDeclaration("membership", bytes, { kind: "membership", repoKey, commit: obs.commit, path: "oats-membership.yaml" });
425
+ return read.problems ? [] : labelList(read.value.team) ?? [];
426
+ } catch (e) { if (e?.code === "E_REMOTE_PATH_MISSING") return []; throw e; }
427
+ }
428
+
377
429
  const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: false, reason, detail, ...extra });
378
430
 
379
431
  /**
@@ -422,21 +474,28 @@ export async function confirmMembership(workspaceObs, memberRef, { remote: injec
422
474
  const caseOnly = backlinkKey.toLowerCase() === workspaceObs.key.toLowerCase();
423
475
  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
476
  }
425
- return { key, commit: obs.commit, confirmed: true, team: read.value.team ?? null };
477
+ const labels = labelList(read.value.team) ?? [];
478
+ return { key, commit: obs.commit, confirmed: true, team: labels[0] ?? null, labels };
426
479
  }
427
480
 
428
481
  /* ───────────────────────────── enumeration ────────────────────────────── */
429
482
 
430
483
  const SOUL_FILE = /^([^/]+)\/soul\.yaml$/;
431
484
  const CAP_FILE = /^([^/]+)\/oats\.json$/;
432
- const teamOf = (item, fallback) => (typeof item?.team === "string" ? item.team : fallback ?? null);
485
+ /** A declared `team` as a label list (teams contract 2026-09-25: a label or a non-empty list of distinct
486
+ * labels, the first the primary); null when the item declares none. */
487
+ const labelList = (team) => (typeof team === "string" ? [team] : Array.isArray(team) ? team.filter((l) => typeof l === "string") : null);
488
+ /** The item's labels, else the repo default's (oats-membership.yaml). */
489
+ const labelsOf = (item, fallback) => labelList(item?.team) ?? [...(fallback || [])];
490
+ /** Each label with the path it is declared at: `/team` for one label, `/team/<i>` in a list. */
491
+ const labelPaths = (item, labels, file) => labels.map((label, i) => [label, Array.isArray(item?.team) ? `${file}#/team/${i}` : `${file}#/team`]);
433
492
 
434
493
  /**
435
494
  * Enumerate souls/*\/soul.yaml and capabilities/*\/oats.json of one repo at one commit.
436
495
  * Validates each item; collects problems instead of aborting.
437
496
  * → { souls: [SoulEntry], capabilities: [CapEntry], problems: [{ code, path, message, repoKey }] }
438
497
  */
439
- async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, teams = null } = {}) {
498
+ async function enumerateRepo(remote, ref, key, commit, { defaultLabels = [], teams = null } = {}) {
440
499
  const souls = [];
441
500
  const capabilities = [];
442
501
  const problems = [];
@@ -467,9 +526,10 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
467
526
  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
527
  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
528
  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 });
529
+ const labels = labelsOf(definition, defaultLabels);
530
+ for (const [label, at] of labelPaths(definition, labels, file)) checkTeam(label, at);
531
+ // Souls have no private mode since 0.26.0: `private` is accepted, warned (soul-private-ignored) and never hides a soul.
532
+ souls.push({ name: definition.name, path, repoKey: key, commit, team: labels[0] ?? null, labels, private: false, definition });
473
533
  }
474
534
  const capNames = new Map();
475
535
  for (const entry of await list("capabilities")) {
@@ -497,9 +557,13 @@ async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, tea
497
557
  },
498
558
  }, manifest);
499
559
  if (shape.length) { for (const p of shape) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, p.message); continue; }
560
+ // The kernel contract (launch environment, hooks): a manifest the kernel could not run is not listed.
561
+ const contract = manifestContractProblems(manifest);
562
+ if (contract.length) { for (const p of contract) problem("E_WORKSPACE_SCHEMA", `${file}#${p.pointer}`, p.message); continue; }
500
563
  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
564
  capNames.set(manifest.capability, file);
502
- const team = teamOf(manifest, defaultTeam);
565
+ // A capability is LISTED under one team (its own `team:`, else the repo default's primary); it joins none.
566
+ const team = typeof manifest.team === "string" ? manifest.team : defaultLabels[0] ?? null;
503
567
  checkTeam(team, `${file}#/team`);
504
568
  capabilities.push({ name: manifest.capability, path, repoKey: key, commit, team, private: manifest.private === true, manifest });
505
569
  }
@@ -537,7 +601,7 @@ export async function discoverRepo(ref, { at, remote: injected, remoteOptions }
537
601
  } catch (e) {
538
602
  if (e?.code !== "E_REMOTE_PATH_MISSING") throw e;
539
603
  }
540
- const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultTeam: membership?.team ?? null, teams: null });
604
+ const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultLabels: labelList(membership?.team) ?? [], teams: null });
541
605
  return { key: obs.key, commit: obs.commit, membership, ...items, problems: [...problems, ...items.problems] };
542
606
  }
543
607
 
@@ -557,7 +621,7 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
557
621
  const members = [];
558
622
  for (const memberRef of workspace.members || []) {
559
623
  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 };
624
+ const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, labels: confirmation.labels ?? [], souls: [], capabilities: [], publishes: null };
561
625
  if (!confirmation.confirmed) {
562
626
  // Contract: an unconfirmed member contributes nothing but its row.
563
627
  row.reason = confirmation.reason;
@@ -565,8 +629,10 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
565
629
  members.push(row);
566
630
  continue;
567
631
  }
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 });
632
+ for (const [i, label] of row.labels.entries()) {
633
+ 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"})` });
634
+ }
635
+ const items = await enumerateRepo(remote, memberRef, row.key, row.commit, { defaultLabels: row.labels, teams });
570
636
  row.souls = items.souls;
571
637
  row.capabilities = items.capabilities;
572
638
  row.publishes = items.publishes;
@@ -593,11 +659,50 @@ export async function discoverWorkspace(ref, { at, local, remote: injected, remo
593
659
  }
594
660
  const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
595
661
  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 } });
662
+ // The workspace's `external[].team` (one label) overrides the soul's own labels.
663
+ const labels = labelList(entry.team) ?? labelList(read.value.team) ?? [];
664
+ 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`);
665
+ 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 } });
666
+ }
667
+ 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)])] };
668
+ }
669
+
670
+ /** Teams contract decision 5: a soul label the workspace declares in `teams:` but does not map in
671
+ * `messaging.byTeam` is a WARNING, not a problem — the label stays eligible-but-unmapped and the
672
+ * messaging provider falls back to the personal team for it. (An undeclared label is E_TEAM_UNKNOWN.)
673
+ * ONE warning per unmapped label, naming its souls (a personal-only workspace would otherwise print
674
+ * a line per soul): { code, label, souls, paths, message }, sorted by label. */
675
+ const byCodepointOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
676
+ function unmappedLabelWarnings(workspace, members, externalRows, teams) {
677
+ const byTeam = isObject(workspace?.messaging) && isObject(workspace.messaging.byTeam) ? workspace.messaging.byTeam : {};
678
+ const byLabel = new Map();
679
+ const souls = [...members.filter((m) => m.confirmed).flatMap((m) => m.souls), ...externalRows.map((e) => e.soul)];
680
+ for (const soul of souls) {
681
+ for (const label of soul.labels || []) {
682
+ if (!teams.includes(label) || Object.hasOwn(byTeam, label)) continue;
683
+ const row = byLabel.get(label) || { souls: [], paths: [] };
684
+ row.souls.push(soul.name); row.paths.push(`${soul.repoKey}:${soul.path}/soul.yaml#/team`);
685
+ byLabel.set(label, row);
686
+ }
599
687
  }
600
- return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems };
688
+ return [...byLabel.keys()].sort(byCodepointOrder).map((label) => {
689
+ const { souls: names, paths } = byLabel.get(label);
690
+ const order = names.map((n, i) => i).sort((x, y) => byCodepointOrder(names[x], names[y]) || byCodepointOrder(paths[x], paths[y]));
691
+ const sorted = order.map((i) => names[i]);
692
+ return { code: "unmapped-team-label", label, souls: sorted, paths: order.map((i) => paths[i]),
693
+ message: `team ${show(label)} has no messaging.byTeam entry; its souls (${sorted.join(", ")}) fall back to the personal team for it` };
694
+ });
695
+ }
696
+
697
+ /** `private:` in a soul.yaml has no effect since 0.26.0 (human decision 2026-09-25): every soul of a
698
+ * confirmed member, and every external soul, is listed and spawnable. The field is still ACCEPTED
699
+ * (existing files keep validating) and named by ONE warning per soul that carries it:
700
+ * { code: "soul-private-ignored", soul, repoKey, path, message }, sorted by soul name, then path. */
701
+ function privateSoulWarnings(souls) {
702
+ return souls.filter((s) => s.definition && Object.hasOwn(s.definition, "private"))
703
+ .map((s) => ({ code: "soul-private-ignored", soul: s.name, repoKey: s.repoKey, path: `${s.repoKey}:${s.path}/soul.yaml#/private`,
704
+ message: `\`private\` has no effect on a soul since 0.26.0; remove it from ${s.path}/soul.yaml` }))
705
+ .sort((a, b) => byCodepointOrder(a.soul, b.soul) || byCodepointOrder(a.path, b.path));
601
706
  }
602
707
 
603
708
  /**
@@ -648,7 +753,7 @@ export function standaloneRepo(ref, commit, discovery, { remote: injected } = {}
648
753
  });
649
754
  return {
650
755
  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,
756
+ 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 }],
757
+ external: [], problems, warnings: privateSoulWarnings(souls),
653
758
  };
654
759
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "policy": "docs/official-marketplace.md",
2
+ "policy": "docs/official-catalog.md",
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
@@ -8,27 +8,27 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.12.1",
11
+ "ref": "v1.13.1",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
15
15
  "url": "https://github.com/awebai/oats-jira.git",
16
- "ref": "v1.0.0",
16
+ "ref": "v1.0.1",
17
17
  "path": "oats-package"
18
18
  },
19
19
  "oats.linear": {
20
20
  "url": "https://github.com/awebai/oats-linear.git",
21
- "ref": "v1.0.0",
21
+ "ref": "v1.0.1",
22
22
  "path": "oats-package"
23
23
  },
24
24
  "oats.authoring": {
25
25
  "url": "https://github.com/awebai/oats-authoring.git",
26
- "ref": "v1.0.0",
26
+ "ref": "v1.0.3",
27
27
  "path": "oats-package"
28
28
  },
29
29
  "oats.dev": {
30
30
  "url": "https://github.com/awebai/oats-dev.git",
31
- "ref": "v1.0.0",
31
+ "ref": "v1.0.1",
32
32
  "path": "oats-package"
33
33
  },
34
34
  "oats.framework": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.25.9",
3
+ "version": "0.26.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -3,7 +3,7 @@ name: integration-authoring
3
3
  description: >-
4
4
  Route custom OATS capability-package and integration work to the framework's
5
5
  integrations expert. Use when building, adapting, or debugging a reusable
6
- capability, new task/messaging/knowledge integration, oats.json manifest,
6
+ capability, new tasks/messaging/knowledge core capability, oats.json manifest,
7
7
  lifecycle hook, or operational command—not merely activating an existing
8
8
  package. Triggers: "custom integration", "capability package", "integrate
9
9
  our tracker", "new messaging integration", "write an oats.json".
@@ -12,65 +12,73 @@ description: >-
12
12
  # Capability and integration authoring — delegate
13
13
 
14
14
  A capability package may ship skills, instance instructions, requirements,
15
- namespaced commands, and approved hooks. An integration is the constrained
16
- subtype implementing exactly one fundamental layer. Building either requires
15
+ namespaced commands, and declared hooks. A core capability is the constrained
16
+ kind that fills one of the knowledge, messaging or tasks positions (its
17
+ manifest's `layer` field names which). Building either requires
17
18
  manifest, security, targeting-boundary, collision, and probe discipline; use
18
19
  the framework's **integrations-expert** soul rather than improvising.
19
20
 
20
- If the user only wants an existing package, use:
21
+ If the user only wants an existing package, declare it and give it to souls;
22
+ no build is needed:
21
23
 
22
- ```bash
23
- oats install <source> # external acquisition + exact lock; inactive
24
- oats trust <id> # only if commands/hooks exist
25
- oats use <id> --global|--type <t>|--soul <s>
24
+ ```yaml
25
+ # oats-workspace.yaml (host repository): declaring the package is the trust decision
26
+ packages:
27
+ vendor.review: git:github.com/vendor/review@v1.0.0
28
+ # a soul's soul.yaml, or the workspace defaults: a capability the package exports
29
+ # (a package may export several; the soul names each one it wants)
30
+ capabilities:
31
+ vendor.review: { from: package }
26
32
  ```
27
33
 
28
- ## 1. Locate the OATS framework repository
34
+ Then run `oats sync` (fetch, verify integrity, lock). The oats.setup
35
+ capability's **oats-package-pins** skill has the procedure.
36
+
37
+ ## 1. Verify the expert is available
38
+
39
+ Run `oats souls` in the deployment and confirm it resolves the
40
+ `integrations-expert` soul (a member repository or package provides it). If it
41
+ is absent, ask the human which OATS deployment owns reusable package work;
42
+ never locate or import private kernel files.
29
43
 
30
- Check a local pi package path, then likely locations such as
31
- `~/oats`; verify with `git -C <dir> remote get-url origin`. Avoid
32
- pi-managed git clones because updates reset them. If absent, ask where to
33
- clone `https://github.com/awebai/oats`.
44
+ ## 2. Spawn the expert against the package's repository
34
45
 
35
- ## 2. Spawn the expert against the user's repository
46
+ The package lives in its own repository. Make that repository a member of the
47
+ workspace (or use the member that already holds it), then spawn the expert on
48
+ it:
36
49
 
37
50
  ```bash
38
- node -e "
39
- import('<framework-repo>/lib/core.mjs').then(m => {
40
- const root = '<framework-repo>/agents';
41
- const a = m.findAgent(root, 'integrations-expert');
42
- const r = m.spawnInstance(root, a, {
43
- purpose: '<package-slug>',
44
- repo: '<users-workspace-or-repo>',
45
- work: 'checkout',
46
- task: '<capability intent; layer if any; skills/instructions/commands/hooks; external tools; desired global/group/soul targets; distribution path>',
47
- });
48
- console.log('window:', r.tmux.window, '| attach:', r.attach);
49
- })"
51
+ oats spawn integrations-expert --preview \
52
+ --purpose <package-slug> \
53
+ --repo <member clone of the package repository> \
54
+ --work worktree \
55
+ --task '<capability intent; layer if any; skills/instructions/commands/hooks; external tools; which souls should get it; distribution path>'
56
+ # review the preview, then run the same command without --preview
50
57
  ```
51
58
 
52
- The work tree is the user's repository, where a local package belongs under
53
- `.agents/capabilities/<name>/`. A framework contribution belongs under
54
- `capabilities/<name>/` in the framework worktree; an independently published
55
- package uses its own repository.
59
+ Use `--relation child --relative-to <your-instance>` only when the documented
60
+ workflow makes the expert your child; otherwise leave the spawn unrelated. A
61
+ package is distributed from its own repository as `oats-package/` with a
62
+ version tag; a framework contribution belongs in the framework's repository.
56
63
 
57
64
  ## 3. Brief the design boundary
58
65
 
59
66
  Tell the expert:
60
67
 
61
68
  - whether it is additive or implements exactly one of knowledge/messaging/tasks;
62
- - external requirements and executable surfaces;
69
+ - external requirements and executable surfaces (commands, hooks);
63
70
  - intended distribution and version/compatibility;
64
- - desired config-owned targets and settings; and
65
- - expected skill/instruction/scaffold collisions.
71
+ - which souls or workspace defaults should receive it, and its settings; and
72
+ - expected skill/instruction collisions (a duplicate skill name fails the spawn).
66
73
 
67
- Targets never belong in the manifest. The expert must test exact pi/Claude
68
- instance materialization, generated instructions, lock/trust behavior,
69
- command gating, deterministic hooks, and scaffold ownership as applicable.
74
+ Which souls get a capability is declared by the workspace (`defaults`) and the
75
+ souls (`soul.yaml` `capabilities`), never in the manifest. The expert must
76
+ test exact pi/Claude/Codex instance materialization, generated instructions,
77
+ command gating, deterministic hooks, and the lock's integrity check as
78
+ applicable.
70
79
 
71
80
  ## 4. Hand off
72
81
 
73
- Report the tmux window (`tmux attach -t pi-agents`). The expert follows its
74
- package/integration craft, runs a scaffold-only probe, and leaves acquisition
75
- and activation commands for the user. Its durable lessons harvest back into
76
- its soul.
82
+ Report the new instance (`oats status`). The expert follows its
83
+ package/integration craft, runs a preview-only probe, and leaves the
84
+ `packages:` pin and the `oats sync` for the user.