@awebai/oats 0.25.8 → 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 (185) 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 +334 -72
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +107 -5
  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 +14 -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-okf/bin/oats-okf.mjs +1 -1
  20. package/capabilities/oats-okf/lib/binding-wire.mjs +1 -1
  21. package/capabilities/oats-okf/lib/migration.mjs +2 -2
  22. package/capabilities/oats-okf/lib/sources.mjs +5 -4
  23. package/capabilities/oats-okf/lib/stores.mjs +40 -9
  24. package/capabilities/oats-okf/lib/worker.mjs +3 -3
  25. package/capabilities/oats-okf/oats.json +1 -1
  26. package/capabilities/oats-review/oats.json +3 -2
  27. package/docs/capabilities.md +218 -47
  28. package/docs/capability-manifest.schema.json +13 -4
  29. package/docs/configuration.md +17 -5
  30. package/docs/conventions.md +16 -26
  31. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  32. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  33. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  34. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  35. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  36. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  37. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  38. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  39. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +54 -10
  40. package/docs/design/2026-09-24-phase-d-plan.md +77 -0
  41. package/docs/design/2026-09-25-teams-contract.md +226 -0
  42. package/docs/design/README.md +3 -3
  43. package/docs/design/launch-configurations.md +20 -16
  44. package/docs/design/operations-contract.md +27 -10
  45. package/docs/desktop-cli-api.md +546 -264
  46. package/docs/desktop-instance-start.md +1 -1
  47. package/docs/desktop.md +7 -13
  48. package/docs/execution-targets.md +16 -18
  49. package/docs/first-team.md +14 -17
  50. package/docs/implementation.md +28 -59
  51. package/docs/integrations.md +88 -32
  52. package/docs/knowledge-capability-authoring.md +1 -1
  53. package/docs/knowledge-reference/package-craft.md +10 -8
  54. package/docs/knowledge-theory.md +1 -1
  55. package/docs/knowledge.md +10 -11
  56. package/docs/layers.md +16 -17
  57. package/docs/oats-local.schema.json +29 -1
  58. package/docs/oats-membership.schema.json +5 -3
  59. package/docs/oats-package.schema.json +2 -2
  60. package/docs/oats-workspace.schema.json +1 -1
  61. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  62. package/docs/packages.md +75 -52
  63. package/docs/release-notes/v0.22.0.md +1 -1
  64. package/docs/release-notes/v0.23.1.md +1 -1
  65. package/docs/release-notes/v0.25.9.md +23 -0
  66. package/docs/release-notes/v0.26.0.md +670 -0
  67. package/docs/schedules.md +48 -126
  68. package/docs/soul.schema.json +11 -4
  69. package/docs/souls-and-instances.md +56 -43
  70. package/docs/workspaces.md +80 -58
  71. package/injects/instance-boundary.md +1 -1
  72. package/injects/work-attached.md +1 -1
  73. package/injects/work-workspace.md +2 -2
  74. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  75. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  76. package/lib/capability-contract.mjs +110 -0
  77. package/lib/config-data.mjs +2 -2
  78. package/lib/core.mjs +700 -4824
  79. package/lib/digest.mjs +12 -0
  80. package/lib/instance-inspect.mjs +396 -0
  81. package/lib/instance-lifecycle.mjs +3 -4
  82. package/lib/instance-resolution.mjs +212 -26
  83. package/lib/instruction-composition.mjs +0 -20
  84. package/lib/materialize.mjs +6 -4
  85. package/lib/operator-dispatch.mjs +33 -13
  86. package/lib/packages.mjs +25 -190
  87. package/lib/provider-binding.mjs +4 -2
  88. package/lib/provider-reasons.mjs +3 -68
  89. package/lib/resolve.mjs +204 -68
  90. package/lib/schedule.mjs +97 -272
  91. package/lib/servers.mjs +13 -13
  92. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  93. package/lib/tree-copy.mjs +44 -0
  94. package/lib/workspace.mjs +125 -20
  95. package/package-catalog.json +7 -7
  96. package/package.json +1 -1
  97. package/skills/integration-authoring/SKILL.md +48 -40
  98. package/skills/oats-getting-started/SKILL.md +105 -110
  99. package/skills/oats-support/SKILL.md +2 -2
  100. package/skills/soul-craft/SKILL.md +13 -6
  101. package/bin/oats-pi-sdk-host.mjs +0 -17
  102. package/docs/2026-09-03-architecture-proposal.md +0 -642
  103. package/docs/artifact-approvals.schema.json +0 -7
  104. package/docs/captured-invocation-context.schema.json +0 -7
  105. package/docs/captured-resolution.schema.json +0 -7
  106. package/docs/design/package-engine-contract.md +0 -813
  107. package/docs/design/package-runtime-api.md +0 -588
  108. package/docs/desktop-succession.md +0 -57
  109. package/docs/execution-capsule.schema.json +0 -108
  110. package/docs/first-team-demo.md +0 -92
  111. package/docs/knowledge-migration.md +0 -147
  112. package/docs/migration-from-oas.md +0 -103
  113. package/docs/oats-config.schema.json +0 -172
  114. package/docs/oats-lock-v3.schema.json +0 -7
  115. package/docs/oats-lock.schema.json +0 -175
  116. package/docs/operating-team-migration.md +0 -470
  117. package/docs/portable.schema.json +0 -2512
  118. package/docs/provider-check-input.schema.json +0 -7
  119. package/docs/rebuild-to-v2.md +0 -511
  120. package/docs/workspace-adoption.md +0 -74
  121. package/injects/framework-workspace.md +0 -7
  122. package/injects/local-soul.md +0 -19
  123. package/injects/oats-portable.md +0 -20
  124. package/injects/oats.md +0 -11
  125. package/injects/portable-instance-boundary.md +0 -39
  126. package/injects/portable-work-directory.md +0 -29
  127. package/lib/artifact-approvals.mjs +0 -120
  128. package/lib/artifact-tree.mjs +0 -141
  129. package/lib/capability-artifacts.mjs +0 -179
  130. package/lib/capability-execution.mjs +0 -15
  131. package/lib/capability-inputs.mjs +0 -39
  132. package/lib/capability-provenance.mjs +0 -231
  133. package/lib/captured-action-shape.mjs +0 -21
  134. package/lib/captured-admission-shape.mjs +0 -20
  135. package/lib/captured-binding-file.mjs +0 -36
  136. package/lib/captured-dispatch.mjs +0 -66
  137. package/lib/captured-instance-index.mjs +0 -277
  138. package/lib/captured-invocation-context.mjs +0 -130
  139. package/lib/captured-launch-request.mjs +0 -66
  140. package/lib/captured-operation-process.mjs +0 -15
  141. package/lib/captured-pi-custody.mjs +0 -29
  142. package/lib/captured-pi-host.mjs +0 -167
  143. package/lib/captured-pi-outcome.mjs +0 -172
  144. package/lib/captured-resolutions.mjs +0 -275
  145. package/lib/captured-scaffold.mjs +0 -87
  146. package/lib/captured-selector.mjs +0 -28
  147. package/lib/captured-session-backend.mjs +0 -52
  148. package/lib/captured-source-receipt-file.mjs +0 -72
  149. package/lib/helper-injection-policy.mjs +0 -104
  150. package/lib/legacy-lock-codec.mjs +0 -106
  151. package/lib/manifest-settings.mjs +0 -84
  152. package/lib/package-closure.mjs +0 -48
  153. package/lib/package-materialization.mjs +0 -83
  154. package/lib/pi-sdk-host.mjs +0 -229
  155. package/lib/portable-artifacts.mjs +0 -115
  156. package/lib/portable-choices.mjs +0 -82
  157. package/lib/portable-composition.mjs +0 -136
  158. package/lib/portable-digest.mjs +0 -105
  159. package/lib/portable-identity.mjs +0 -40
  160. package/lib/portable-lock.mjs +0 -117
  161. package/lib/portable-onboarding-request.mjs +0 -49
  162. package/lib/portable-onboarding.mjs +0 -256
  163. package/lib/portable-package-preparation.mjs +0 -188
  164. package/lib/portable-policy.mjs +0 -44
  165. package/lib/portable-soul.mjs +0 -42
  166. package/lib/portable-state.mjs +0 -80
  167. package/lib/prepare-composition.mjs +0 -170
  168. package/lib/prepared-bindings.mjs +0 -92
  169. package/lib/prepared-resources.mjs +0 -127
  170. package/lib/provider-binding-broker.mjs +0 -65
  171. package/lib/provider-binding-wire.mjs +0 -116
  172. package/lib/readiness.mjs +0 -225
  173. package/lib/repository-observation.mjs +0 -226
  174. package/lib/resolution-shape.mjs +0 -393
  175. package/lib/schedule-capsule.mjs +0 -206
  176. package/lib/soul-constraints.mjs +0 -40
  177. package/lib/source-projection.mjs +0 -84
  178. package/lib/source-spec.mjs +0 -189
  179. package/lib/workspace-definition.mjs +0 -126
  180. package/lib/workspace-discovery.mjs +0 -146
  181. package/skills/oats/SKILL.md +0 -162
  182. package/skills/oats-config/SKILL.md +0 -164
  183. package/skills/oats-packages/SKILL.md +0 -184
  184. package/skills/oats-portable/SKILL.md +0 -115
  185. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/digest.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /** The raw-byte digest (`oats.bytes.v1`): a config document's integrity. */
2
+ import { createHash } from "node:crypto";
3
+ import { oatsError } from "./errors.mjs";
4
+
5
+ export const BYTES_FORMAT = "oats.bytes.v1";
6
+
7
+ export function bytesIntegrity(value) {
8
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
9
+ throw oatsError("invalid-declaration", "raw-byte integrity requires bytes");
10
+ }
11
+ return { format: BYTES_FORMAT, value: `sha256-${createHash("sha256").update(value).digest("hex")}` };
12
+ }
@@ -0,0 +1,396 @@
1
+ /** inspect / readiness / operation targets on the workspace model (lead decision 4).
2
+ *
3
+ * A TARGET is what the answer is about, read from the workspace model's own
4
+ * records and never from the classic config chain:
5
+ * - an instance (`--home`): its instance.json (modules, providers = the merged
6
+ * payloads, workspace, soulDir, policy) and its materialized module copies;
7
+ * - a soul (`--soul`): its resolution (prepareInstance → modules, payloads,
8
+ * slots), the discovery it came from, and its declarations.
9
+ * The integers are the contract: inspect `operationsApi: 2` with `soulsApi: 2`
10
+ * soul rows, readiness `readinessApi: 2`. Payloads carry no scope chain, team
11
+ * block or config levels, and there is no `trusted` check: declaring a package
12
+ * in `packages:` is the trust decision. */
13
+ import { spawnSync } from "node:child_process";
14
+ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, withConfigFile } from "./core.mjs";
18
+ import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
+ import { declaredSettings } from "./capability-contract.mjs";
20
+ import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
21
+ import { loadLocal } from "./workspace.mjs";
22
+ import { ensureModuleTree } from "./operator-dispatch.mjs";
23
+ /** Bounds of a provider check's answer (bytes, nesting, entries), as the released binding wire set them. */
24
+ const BINDING_LIMITS = Object.freeze({ maxBytes: 1024 * 1024, maxDepth: 32, maxEntries: 16384 });
25
+ import { parseStrictJson } from "./canonical-json.mjs";
26
+
27
+ export const INSPECT_OPERATIONS_API = 2;
28
+ export const SOULS_API = 2;
29
+ export const READINESS_API = 2;
30
+ const LAYERS = ["knowledge", "messaging", "tasks"];
31
+ const CLI_BIN = fileURLToPath(new URL("../bin/oats.mjs", import.meta.url));
32
+ const obj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
33
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
34
+
35
+ /** A resolution refusal that is a readiness FACT (the soul cannot resolve as the
36
+ * lock stands), not a failure to answer: reported as a failing `installed` item. */
37
+ export const RESOLUTION_FACTS = new Set(["E_PACKAGE_MISSING", "E_PACKAGE_INTEGRITY", "E_CAPABILITY_MISSING", "E_LOCK_SCHEMA", "E_REQUIREMENT_INACTIVE"]);
38
+
39
+ /** Is `name` an executable on PATH? (manifest `requires`, no shell). */
40
+ function onPath(name) {
41
+ if (typeof name !== "string" || !name || name.includes("/")) return false;
42
+ for (const dir of String(process.env.PATH || "").split(delimiter)) {
43
+ if (!dir) continue;
44
+ try { accessSync(join(dir, name), fsConstants.X_OK); if (statSync(join(dir, name)).isFile()) return true; } catch { /* next */ }
45
+ }
46
+ return false;
47
+ }
48
+ const manifestRequires = (manifest) => (Array.isArray(manifest?.requires) ? manifest.requires : [])
49
+ .filter((r) => obj(r) && typeof r.command === "string" && r.command)
50
+ .map((r) => ({ command: r.command, why: r.why || null, install: r.install || null }));
51
+ const missingRequires = (manifest) => manifestRequires(manifest).filter((r) => !onPath(r.command));
52
+
53
+ /** A soul's own declarations, parsed by the kernel (never by a consumer). */
54
+ function declarationsOf(definition) {
55
+ const d = obj(definition) ? definition : {};
56
+ const section = (key) => (d[key] === undefined ? null : d[key]);
57
+ return { requires: section("requires"), defaults: section("defaults"), knowledge: section("knowledge"), teams: section("teams"), resources: section("resources"), children: section("children"), capabilities: section("capabilities") };
58
+ }
59
+ function readSoulYaml(soulDir) {
60
+ const file = soulDir && join(soulDir, "soul.yaml");
61
+ if (!file || !existsSync(file)) return { definition: null, problems: [] };
62
+ try { return { definition: withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8"))), problems: [] }; }
63
+ catch (e) { return { definition: null, problems: [{ code: "soul-declarations-unreadable", message: e.message }] }; }
64
+ }
65
+ function readTextCapped(file, cap = 200_000) {
66
+ try { const text = readFileSync(file, "utf8"); return { file, text: text.length > cap ? text.slice(0, cap) : text, truncated: text.length > cap }; }
67
+ catch { return { file, text: null, truncated: false }; }
68
+ }
69
+
70
+ /** The instance target: `home` is absolute and holds instance.json with modules.
71
+ * `teams` is the home's LIVE eligible teams (teams contract decision 6), read from this
72
+ * target's discovery when it discovers, else by liveTeams; `teamsSource` says which
73
+ * ("live" | "recorded": the spawn-time record, when the workspace cannot answer now).
74
+ * `live: false` skips the live read — a caller that never hands the teams to the
75
+ * messaging provider (a non-messaging operation) gets the record at no remote cost. */
76
+ export async function homeTarget(home, meta, { remoteOptions, discover = true, live: readLive = true } = {}) {
77
+ const realHome = real(home);
78
+ // Only the home's own module copies: without <home>/.oats/modules the kernel's
79
+ // manifest lookup would fall through to a classic config chain — never here.
80
+ let manifests = {}; const loadProblems = [];
81
+ if (existsSync(join(realHome, ".oats", "modules"))) {
82
+ try { manifests = capabilityManifests(realHome); } catch (e) { loadProblems.push({ code: "module-manifest-invalid", message: e.message }); }
83
+ }
84
+ const modules = obj(meta.modules) ? meta.modules : {};
85
+ const agentsRoot = dirname(dirname(dirname(realHome)));
86
+ const deployment = dirname(agentsRoot);
87
+ const ws = obj(meta.workspace) ? meta.workspace : {};
88
+ const soulDir = instanceSoulDir(realHome, meta) ?? null;
89
+ const { definition, problems } = readSoulYaml(soulDir);
90
+ let discovery = null, discoveryError = null;
91
+ if (discover) {
92
+ try {
93
+ // The derived deployment exactly — never an oats-local.yaml found further up.
94
+ const found = loadLocal(deployment);
95
+ if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
96
+ discovery = await discoverOrStandalone(found.local, { remoteOptions });
97
+ }
98
+ catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
99
+ }
100
+ // External or member soul: spawn records the soul's repoKey, not its kind, so the
101
+ // kind is observed (readiness discovers); null when not observed (inspect --home).
102
+ let external = null;
103
+ if (discovery) {
104
+ try { const e = findSoulEntry(discovery, meta.agent); if (e.repoKey === ws.soul?.repoKey) external = e.external === true; } catch { /* no longer declared */ }
105
+ if (external === null && (discovery.members || []).some((m) => m.key === ws.soul?.repoKey)) external = false;
106
+ }
107
+ const slots = Object.fromEntries(LAYERS.map((l) => [l, Object.keys(modules).find((n) => manifests[n]?.layer === l) ?? null]));
108
+ let live;
109
+ if (discovery) {
110
+ let entry = null; try { entry = findSoulEntry(discovery, meta.agent); } catch { /* no longer listed */ }
111
+ live = entry && entry.repoKey === ws.soul?.repoKey
112
+ ? { teams: teamsOf(discovery.standalone === true ? null : discovery.workspace, teamLabelsOf(entry)), source: "live" }
113
+ : { teams: Array.isArray(meta.teams) ? meta.teams : null, source: "recorded" };
114
+ } else if (readLive) live = await liveTeams(realHome, meta, { remoteOptions });
115
+ else live = { teams: Array.isArray(meta.teams) ? meta.teams : null, source: "recorded" };
116
+ return {
117
+ kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, teamsSource: live.source,
118
+ subject: { kind: "instance", instance: meta.instance, home, soul: meta.agent },
119
+ soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, team: ws.soul?.team ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
120
+ workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
121
+ modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
122
+ payloads: obj(meta.providers) ? meta.providers : {}, slots, slotsFrom: Object.fromEntries(LAYERS.map((l) => [l, obj(ws.layers?.[l]) ? ws.layers[l].from ?? null : null])), discovery, discoveryError, resolutionError: null,
123
+ lock: null, prepared: null,
124
+ };
125
+ }
126
+
127
+ /** The soul target, resolved exactly as a spawn of `soul` would be. A resolution
128
+ * refusal that is a readiness fact is kept as `resolutionError` (callers that
129
+ * must answer — inspect, operation run — refuse with it). */
130
+ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
131
+ let prepared = null, resolutionError = null;
132
+ try { prepared = await prepareInstance(contextDir, soul, { remoteOptions }); }
133
+ catch (e) {
134
+ if (!RESOLUTION_FACTS.has(e?.code)) throw e;
135
+ resolutionError = { code: e.code, message: e.message, details: e.details ?? null };
136
+ }
137
+ const found = loadLocal(contextDir);
138
+ const deployment = real(prepared?.deployment ?? (found.path ? dirname(found.path) : resolve(contextDir)));
139
+ let discovery = prepared?.discovery ?? null, soulEntry = prepared?.soulEntry ?? null;
140
+ if (!prepared) {
141
+ // Still name the soul (and its member) when its resolution is refused.
142
+ discovery = await discoverOrStandalone(found.local, { remoteOptions });
143
+ soulEntry = findSoulEntry(discovery, soul);
144
+ }
145
+ const res = prepared?.resolution;
146
+ // A refused resolution still names its eligible teams: the labels and the workspace are known.
147
+ const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
148
+ const cached = soulEntry?.commit ? join(deployment, "agents", soulEntry.name, "souls", String(soulEntry.commit).slice(0, 12)) : null;
149
+ return {
150
+ kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
151
+ subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },
152
+ soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
153
+ soulDir: cached && existsSync(join(cached, "soul.yaml")) ? cached : null, definition: soulEntry.definition ?? null, problems: [] },
154
+ workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
155
+ modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
156
+ payloads: obj(res?.payloads) ? res.payloads : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
157
+ discovery, discoveryError: null, resolutionError, lock: prepared?.lock ?? null, prepared,
158
+ };
159
+ }
160
+
161
+ /** Is `dir` a v2 context (an oats-local.yaml in reach)? Only a MISSING file means
162
+ * "not a workspace"; an unreadable or invalid one is the caller's error to report,
163
+ * never a silent fallback to the classic chain. */
164
+ export function isWorkspaceContext(dir) {
165
+ try { loadLocal(dir); return true; }
166
+ catch (e) { if (e?.code === "E_LOCAL_MISSING") return false; throw e; }
167
+ }
168
+
169
+ /** The module's own kernel range against the running kernel: `{ ok, range, kernel }`; an
170
+ * unparseable range is not ok. A soul's resolution already refuses an incompatible module, so
171
+ * `ok: false` shows only for a home whose spawned module no longer admits this kernel. */
172
+ function compatibilityRow(name, manifest) {
173
+ try { return kernelCompatibility({ ...manifest, capability: name }); }
174
+ catch (e) { if (e?.code === "E_CAPABILITY_INCOMPATIBLE") return { ok: false, range: e.details?.range ?? null, kernel: e.details?.kernel ?? null }; throw e; }
175
+ }
176
+ function capabilityRows(t) {
177
+ return t.modules.map(({ name, from, manifest, dir }) => {
178
+ const m = manifest || {};
179
+ const missing = missingRequires(m);
180
+ const operations = manifestOperations(m).map((op) => {
181
+ let reason = null;
182
+ if (missing.length) reason = `${name} requires ${missing.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
183
+ else if (op.context === "home" && !t.home) reason = "needs a running home (--home)";
184
+ return { ...op, argv: [m.command ?? null, op.command], available: !reason, reason };
185
+ });
186
+ return { id: name, version: m.version ?? null, layer: m.layer ?? null, command: m.command ?? null, from, dir,
187
+ settings: obj(t.payloads[name]) ? t.payloads[name] : {}, declares: declaredSettings(m), compatibility: compatibilityRow(name, m), missingRequires: missing, operations };
188
+ });
189
+ }
190
+ function soulRow(t) {
191
+ const s = t.soul, def = obj(s.definition) ? s.definition : {};
192
+ return { soulsApi: SOULS_API, name: s.name, repoKey: s.repoKey, commit: s.commit, team: s.team, kind: s.external === true ? "external" : s.external === false ? "member" : null, path: s.path,
193
+ description: def.description ?? null, work: def.work ?? null, runtime: def.runtime ?? null, model: def.model ?? null,
194
+ declarations: declarationsOf(s.definition), declarationProblems: s.problems,
195
+ instructions: s.soulDir ? readTextCapped(join(s.soulDir, "AGENTS.md")) : null };
196
+ }
197
+
198
+ /** `oats inspect --json` on the workspace model. */
199
+ export function inspectDocument(t, { kernel }) {
200
+ const capabilities = capabilityRows(t);
201
+ // `from` (feature layers-from): where the slot's capability came from; null for an empty slot, and for a
202
+ // home spawned before the kernel recorded it.
203
+ const layers = Object.fromEntries(LAYERS.map((l) => [l, { id: t.slots[l] ?? null, from: t.slots[l] ? t.slotsFrom?.[l] ?? null : null }]));
204
+ const kcap = layers.knowledge.id ? capabilities.find((c) => c.id === layers.knowledge.id) : null;
205
+ const m = t.meta;
206
+ return {
207
+ operationsApi: INSPECT_OPERATIONS_API, kernel, subject: t.subject, workspace: t.workspace,
208
+ souls: [soulRow(t)], layers, capabilities,
209
+ // Teams contract item 7 (feature `teams`): the ELIGIBLE teams, one per soul label in order —
210
+ // live for a home (`teamsSource: "recorded"` when its workspace could not be read now).
211
+ teams: t.teams ?? null, teamsSource: t.teamsSource ?? null,
212
+ knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
213
+ instance: m ? { home: t.home, instance: m.instance, agent: m.agent, runtime: m.runtime || null, model: m.model ?? null, yolo: m.yolo ?? null, launched: !!m.launched, createdAt: m.createdAt || null,
214
+ resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
215
+ ...(m ? { identity: servedIdentityOf(m) } : {}),
216
+ problems: [...t.soul.problems, ...(t.discoveryError ? [t.discoveryError] : []),
217
+ ...t.modules.filter((x) => !x.manifest).map((x) => ({ code: "module-missing", message: `${x.name} is recorded but its module copy has no readable oats.json`, capability: x.name })),
218
+ ...capabilities.filter((c) => !c.compatibility.ok).map((c) => ({ code: "capability-incompatible", message: `${c.id} requires oats ${c.compatibility.range}; this kernel is ${c.compatibility.kernel} (re-spawn from the deployment with a release whose range admits it)`, capability: c.id, range: c.compatibility.range, kernel: c.compatibility.kernel }))],
219
+ };
220
+ }
221
+
222
+ // ---------- readiness ----------
223
+ function roll(items) {
224
+ const req = items.filter((i) => i.required !== false);
225
+ if (!items.length) return "not-applicable";
226
+ if (req.some((i) => i.status === "fail")) return "fail";
227
+ if (req.some((i) => i.status === "unknown")) return "unknown";
228
+ return req.length ? "pass" : "not-applicable";
229
+ }
230
+ const item = (subject, status, { required = true, reason = null, producer = "kernel", evidence = null, remedy = null, ...rest } = {}) => ({ subject, status, required, reason, producer, evidence, remedy, ...rest });
231
+
232
+ /** The team/workspace facts a provider receives, as hooks get them (teamEnv). */
233
+ function providerEnv(t, capability, settings) {
234
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(OATS_|OAS_|PI_)/.test(k)));
235
+ Object.assign(env, teamEnv({ workspace: { key: t.workspace.key, name: t.workspace.name, deployment: t.deployment, team: t.soul.team, slots: t.slots }, payloads: t.payloads, teams: t.teams, teamsSource: t.teamsSource }));
236
+ return Object.assign(env, { OATS_CAPABILITY: capability, OATS_SETTINGS: JSON.stringify(settings), OATS_CLI_BIN: CLI_BIN, OATS_WORKSPACE: t.deployment,
237
+ ...(t.home ? { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home } : {}), OATS_AGENT: t.soul.name, ...(t.soul.soulDir ? { OATS_SOUL: t.soul.soulDir } : {}) });
238
+ }
239
+ /** An executable inside a module directory: both sides realpath'd (a symlink out of
240
+ * the module is an escape, as for the kernel's manifestPath), a regular file. */
241
+ function moduleExecutable(dir, script) {
242
+ let root, file;
243
+ try { root = realpathSync(dir); file = realpathSync(resolve(dir, script)); } catch { return null; }
244
+ const rel = relative(root, file);
245
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
246
+ try { return statSync(file).isFile() ? file : null; } catch { return null; }
247
+ }
248
+ /** The four check statuses of the binding wire, and what each means for readiness. */
249
+ const PROVIDER_STATUS_ITEM = Object.freeze({ ready: "pass", "needs-configuration": "fail", "authorization-required": "fail", unavailable: "unknown" });
250
+ const ENVELOPE_KEYS = ["schemaVersion", "phase", "slot", "capability", "ok"];
251
+ function validProblems(p) { return Array.isArray(p) && p.every((x) => obj(x) && typeof x.code === "string" && typeof x.message === "string"); }
252
+ const codeMessages = (p) => p.map((x) => ({ code: x.code, message: x.message }));
253
+ /** Run one provider's binding check. → { outcome: "result", result: {status, problems,
254
+ * warnings} } verbatim, or `unknown` with the provider's own refusal (never a kernel
255
+ * invention). The answer is decoded by the released binding wire's response rules:
256
+ * exit 0, exactly one strict JSON
257
+ * document within BINDING_LIMITS, the exact envelope echoing schemaVersion/phase/
258
+ * slot/capability, one of the four statuses, and no problems on `ready`. Problem codes
259
+ * are the provider's own (not checked against binding.reasons, which are refusal
260
+ * sentences); problems and the optional warnings must be {code, message} strings. */
261
+ export function runProviderCheck(t, mod, dir, { timeoutMs = 30000 } = {}) {
262
+ const m = mod.manifest, check = m?.binding?.check, spec = check ? m.commands?.[check] : null;
263
+ const unavailable = (code, message) => ({ outcome: "unknown", problems: [{ code, message }] });
264
+ if (typeof spec !== "string" || !spec.trim()) return unavailable("provider-not-qualified", `${mod.name} declares binding.check but no command ${JSON.stringify(check)}`);
265
+ const [script, ...args] = spec.trim().split(/\s+/);
266
+ const file = dir ? moduleExecutable(dir, script) : null;
267
+ if (!file) return unavailable("resource-not-found", `${mod.name}: the check executable ${script} is unavailable`);
268
+ const settings = obj(t.payloads[mod.name]) ? t.payloads[mod.name] : {};
269
+ // The released binding wire, key for key: providers decode it strictly (oats.aweb 1.13.1
270
+ // refuses any other key). The eligible teams reach the check through its environment
271
+ // (OATS_TEAMS / OATS_TEAMS_SOURCE / OATS_TEAM_LABELS, providerEnv), never on stdin.
272
+ const request = { schemaVersion: 1, phase: "check", slot: m.layer ?? null, capability: mod.name, settings,
273
+ input: { context: { kind: "workspace", workspace: t.workspace.key, deployment: t.deployment, soul: t.soul.name, team: t.soul.team, instance: t.meta?.instance ?? null, home: t.home }, action: { kind: "readiness" } } };
274
+ // The environment is providerEnv's: ambient OATS_/OAS_/PI_ identity removed, the rest
275
+ // inherited.
276
+ const r = spawnSync(process.execPath, [file, ...args], { cwd: dir, env: providerEnv(t, mod.name, settings), input: JSON.stringify(request), encoding: "utf8", timeout: Math.max(1, timeoutMs), killSignal: "SIGKILL", maxBuffer: BINDING_LIMITS.maxBytes, stdio: ["pipe", "pipe", "pipe"] });
277
+ if (r.error || r.signal) return unavailable("provider-unavailable", `${mod.name} check did not complete${r.error?.code === "ETIMEDOUT" || r.signal ? " in time" : ""}`);
278
+ if (r.status !== 0) return unavailable("provider-unavailable", `${mod.name} check exited ${r.status}`);
279
+ const invalid = () => unavailable("provider-unavailable", `${mod.name} check answered invalid binding data`);
280
+ let doc;
281
+ try {
282
+ doc = parseStrictJson(r.stdout || "", BINDING_LIMITS);
283
+ if (!obj(doc)) return invalid();
284
+ const allowed = [...ENVELOPE_KEYS, doc.ok === true ? "result" : "error"];
285
+ if (Object.keys(doc).some((k) => !allowed.includes(k)) || allowed.some((k) => !Object.hasOwn(doc, k))) return invalid();
286
+ for (const key of ["schemaVersion", "phase", "slot", "capability"]) if (doc[key] !== request[key]) return invalid();
287
+ } catch { return invalid(); }
288
+ if (doc.ok === false) {
289
+ const e = doc.error;
290
+ if (!obj(e) || typeof e.code !== "string" || (e.message !== undefined && typeof e.message !== "string")) return invalid();
291
+ return unavailable(e.code, e.message ?? `${mod.name} refused the check`);
292
+ }
293
+ const res = doc.result;
294
+ if (doc.ok !== true || !obj(res) || Object.keys(res).some((k) => !["status", "problems", "warnings"].includes(k))
295
+ || !Object.hasOwn(PROVIDER_STATUS_ITEM, res.status) || !validProblems(res.problems)
296
+ // `warnings` is optional (absent → []), validated as strictly as problems, and never
297
+ // changes the status (e.g. a ready binding with end-to-end encryption disabled).
298
+ || (res.warnings !== undefined && !validProblems(res.warnings))
299
+ || (res.status === "ready" && res.problems.length)) return invalid();
300
+ return { outcome: "result", result: { status: res.status, problems: codeMessages(res.problems), warnings: codeMessages(res.warnings ?? []) } };
301
+ }
302
+ /** The total time the providers check may take in one readiness read. */
303
+ export const PROVIDER_CHECK_BUDGET_MS = 60_000;
304
+
305
+ /** `oats readiness --json` on the workspace model. */
306
+ export async function readinessDocument(t, { selector = null, remoteOptions, catalog = null, budgetMs = PROVIDER_CHECK_BUDGET_MS } = {}) {
307
+ const installed = [], configured = [], member = [], providers = [];
308
+ const cap = (id) => ({ capability: { id } });
309
+ if (t.resolutionError) {
310
+ installed.push(item(t.soul.name, "fail", { producer: "workspace resolution", code: t.resolutionError.code, reason: t.resolutionError.message, evidence: t.resolutionError.details, remedy: "oats sync (the lock must provide every package capability the soul resolves)" }));
311
+ }
312
+ for (const mod of t.modules) {
313
+ const present = t.home ? existsSync(join(mod.dir, "oats.json")) && !!mod.manifest : !!mod.manifest;
314
+ installed.push(item(mod.name, present ? "pass" : "fail", { producer: t.home ? "instance modules" : "workspace resolution", evidence: { from: mod.from },
315
+ reason: present ? null : "the module copy is missing from the home", remedy: present ? null : "spawn a new instance of this soul", ...cap(mod.name) }));
316
+ for (const req of manifestRequires(mod.manifest)) {
317
+ const found = onPath(req.command);
318
+ configured.push(item(`${mod.name} requires ${req.command}`, found ? "pass" : "fail", { producer: "capability manifest", reason: found ? null : req.why || "required command not on PATH",
319
+ evidence: { command: req.command }, remedy: found ? null : req.install, ...cap(mod.name) }));
320
+ }
321
+ }
322
+ // member — the soul's member repository is a confirmed member of the workspace
323
+ // (oats-membership.yaml backlink observed over the remotes).
324
+ const d = t.discovery;
325
+ if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
326
+ else if (!d) member.push(item(`member ${t.soul.repoKey ?? "?"}`, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence: { repoKey: t.soul.repoKey } }));
327
+ // A standalone view is an allowed mode (decision 10): membership cannot be confirmed
328
+ // there by definition, so it is not a readiness requirement (as for an external soul).
329
+ else if (d.standalone === true) member.push(item(`member ${t.soul.repoKey}`, "not-applicable", { required: false, producer: "workspace discovery", reason: `standalone view (${d.standaloneReason ?? "explicit"}): the workspace this repository declares is not read, so its membership is declared, not confirmed`, evidence: { repoKey: t.soul.repoKey, workspace: d.key, standaloneReason: d.standaloneReason ?? null } }));
330
+ else {
331
+ const row = (d.members || []).find((x) => x.key === t.soul.repoKey);
332
+ member.push(item(`member ${t.soul.repoKey}`, row?.confirmed ? "pass" : "fail", { producer: "workspace discovery",
333
+ reason: row?.confirmed ? null : row ? `not confirmed: ${row.reason ?? "unconfirmed"}${row.detail ? ` (${row.detail})` : ""}` : "not a member of the workspace",
334
+ evidence: { repoKey: t.soul.repoKey, workspace: d.key, commit: row?.commit ?? null },
335
+ remedy: row?.confirmed ? null : "the member repository must carry oats-membership.yaml naming this workspace, and the workspace must list it in members:" }));
336
+ }
337
+ // providers — each bound provider's own binding check, verbatim, within one total budget.
338
+ const deadline = Date.now() + budgetMs;
339
+ for (const mod of t.modules) {
340
+ if (!obj(mod.manifest?.binding)) continue;
341
+ let dir = t.home ? mod.dir : null, pre = null;
342
+ if (Date.now() >= deadline) pre = { code: "time-budget-exhausted", message: `time budget exhausted: the providers check stops after ${Math.round(budgetMs / 1000)} s per readiness read` };
343
+ if (!pre && !dir && mod.module && t.lock) { try { dir = await ensureModuleTree(t.deployment, mod.module, t.lock, { catalog, remoteOptions }); } catch (e) { pre = { code: e.code || "provider-unavailable", message: e.message }; } }
344
+ if (!dir && !pre && mod.module?.from?.kind === "member") pre = { code: "resource-not-found", message: `${mod.name}: a member module runs from a spawned home; inspect an instance (--home) for its check` };
345
+ const out = pre ? { outcome: "unknown", problems: [pre] } : runProviderCheck(t, mod, dir, { timeoutMs: Math.min(30_000, deadline - Date.now()) });
346
+ if (out.outcome === "result") {
347
+ const status = PROVIDER_STATUS_ITEM[out.result.status];
348
+ providers.push(item(mod.name, status, { producer: "provider binding check", result: out.result,
349
+ reason: status === "pass" ? null : out.result.problems[0]?.message ?? `the provider reports ${out.result.status}`, ...cap(mod.name) }));
350
+ }
351
+ else providers.push(item(mod.name, "unknown", { producer: "provider binding check", result: null, problems: out.problems, reason: out.problems[0]?.message ?? null, ...cap(mod.name) }));
352
+ }
353
+ const checks = { installed: { status: roll(installed), items: installed }, configured: { status: roll(configured), items: configured }, member: { status: roll(member), items: member }, providers: { status: roll(providers), items: providers } };
354
+ const requiredStatuses = Object.values(checks).flatMap((c) => c.items.filter((i) => i.required).map((i) => i.status));
355
+ const byCapability = [...new Set(Object.values(checks).flatMap((c) => c.items.map((i) => i.capability?.id).filter(Boolean)))].sort().map((id) => {
356
+ const of = (name) => checks[name].items.filter((i) => i.capability?.id === id);
357
+ const statuses = Object.keys(checks).flatMap((name) => of(name).filter((i) => i.required).map((i) => i.status));
358
+ return { capability: { id }, checks: Object.fromEntries(Object.keys(checks).map((name) => [name, of(name).length ? roll(of(name)) : "not-applicable"])),
359
+ ownReady: statuses.length > 0 && statuses.every((s) => s === "pass" || s === "not-applicable") };
360
+ });
361
+ const subjectBlockers = Object.entries(checks).flatMap(([name, c]) => c.items.filter((i) => i.required && !i.capability && i.status !== "pass" && i.status !== "not-applicable").map((i) => ({ check: name, subject: i.subject, status: i.status })));
362
+ for (const g of byCapability) g.ready = g.ownReady && subjectBlockers.length === 0;
363
+ return { readinessApi: READINESS_API, subject: t.subject, selector, at: new Date().toISOString(), checks,
364
+ summary: { ready: requiredStatuses.length > 0 && requiredStatuses.every((s) => s === "pass" || s === "not-applicable"), required: requiredStatuses.length,
365
+ pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length, byCapability, subjectBlockers },
366
+ notes: ["ready means every REQUIRED check passes; it is never inferred from an empty set",
367
+ "member is the soul's member repository confirmed in the workspace (oats-membership.yaml), never login or team registration",
368
+ "providers relays each provider's own binding check verbatim; the spawn's fail-closed hooks are unchanged"] };
369
+ }
370
+
371
+ /** Enforced policy for an instance (from its recorded metadata) or a soul
372
+ * (its declaration + default), with origins. Advisory/unknown stays unknown. */
373
+ export function policyOf({ instanceMeta = null, soul = null } = {}) {
374
+ const child = instanceMeta?.policy?.childSpawns
375
+ ? { allowed: instanceMeta.policy.childSpawns.allowed === true, enforced: true, origin: instanceMeta.policy.childSpawns.origin ?? { kind: "recorded" } }
376
+ : instanceMeta ? { allowed: true, enforced: true, origin: { kind: "default", detail: "no recorded policy: children allowed (pre-0.24.8 instance)" } }
377
+ : soul?.declarations?.children && typeof soul.declarations.children.spawn === "boolean"
378
+ ? { allowed: soul.declarations.children.spawn, enforced: false, origin: { kind: "soul", detail: "children.spawn in soul.yaml; enforced once an instance records it" } }
379
+ : { allowed: true, enforced: false, origin: { kind: "default", detail: "no declaration: children allowed" } };
380
+ const worktree = instanceMeta ? { allowed: instanceMeta.work === "worktree" || instanceMeta.work === "checkout", mode: instanceMeta.work ?? null, enforced: true, origin: { kind: "work-mode", detail: `work: ${instanceMeta.work}` } }
381
+ : soul ? { allowed: ["worktree", "checkout"].includes(soul.work), mode: soul.work ?? null, enforced: false, origin: { kind: "soul", detail: `work: ${soul.work}` } } : { allowed: null, mode: null, enforced: false, origin: { kind: "unknown" } };
382
+ return { policy: { childSpawns: child, worktrees: worktree } };
383
+ }
384
+ /** The soul facts `policyOf` reads (children.spawn, work). */
385
+ export const policySoul = (t) => ({ declarations: declarationsOf(t.soul.definition), work: obj(t.soul.definition) ? t.soul.definition.work : undefined });
386
+ /** The provider module filling `layer` for a target, with its directory (fetched
387
+ * into the deployment's module store for a soul). */
388
+ export async function layerProvider(t, layer, { catalog = null, remoteOptions } = {}) {
389
+ const name = t.slots[layer];
390
+ const mod = name ? t.modules.find((x) => x.name === name) : null;
391
+ if (!mod?.manifest) return null;
392
+ let dir = mod.dir;
393
+ if (!dir && mod.module && t.lock) dir = await ensureModuleTree(t.deployment, mod.module, t.lock, { catalog, remoteOptions });
394
+ return { mod, dir, settings: obj(t.payloads[name]) ? t.payloads[name] : {}, env: (cap, settings) => providerEnv(t, cap, settings), executable: (script) => (dir ? moduleExecutable(dir, script) : null) };
395
+ }
396
+ export { missingRequires as manifestMissingRequires };
@@ -10,7 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { basename, join } from "node:path";
13
- import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, retirePendingMarkerPath, stopInstanceSession, teamAgentRoots, resolveOatsConfig } from "./core.mjs";
13
+ import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, retirePendingMarkerPath, stopInstanceSession } from "./core.mjs";
14
14
  import { observeInstanceGit } from "./instance-git.mjs";
15
15
  import { appendEvent } from "./instance-events.mjs";
16
16
  import { oatsError } from "./errors.mjs";
@@ -23,10 +23,9 @@ const stopReceiptPath = (home, key) => join(home, `.oats-stop-receipt.${key}.jso
23
23
  function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } }
24
24
  function planRevision(facts) { return createHash("sha256").update(JSON.stringify(facts)).digest("hex").slice(0, 24); }
25
25
 
26
- /** Resolve `<name>` under the scope's agents roots to exactly one home. */
26
+ /** Resolve `<name>` under the deployment's agents root to exactly one home. */
27
27
  export function resolveInstance(ctx, root, name, { home } = {}) {
28
- let r; try { r = resolveOatsConfig(ctx); } catch { r = {}; }
29
- const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])])];
28
+ const roots = [root];
30
29
  const candidates = [];
31
30
  for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
32
31
  if (home !== undefined) {