@awebai/oats 0.24.12 → 0.25.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 (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
@@ -0,0 +1,576 @@
1
+ /**
2
+ * lib/resolve.mjs — from a soul to an immutable resolution (module contract §3).
3
+ *
4
+ * Contract: docs/design/2026-09-23-workspace-module-contracts.md §3.
5
+ * Decision: agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md (6, 12, 14, 16, 19–21).
6
+ *
7
+ * `resolveSoul(discovery, soulEntry, options)` turns a discovered soul into the
8
+ * exact set of modules an instance will be built from: which capability comes
9
+ * from where (a confirmed member at its latest commit, or a locked package at
10
+ * its pinned commit), which module fills each fundamental slot, the merged
11
+ * provider payload per capability, the composed skill set, the injects, and a
12
+ * `revision` that changes whenever any of that changes.
13
+ *
14
+ * Resolution is a handshake check plus a lookup — never a search:
15
+ * - `from: here` → the soul's own repo.
16
+ * - `from: <repo>` → a CONFIRMED member (E_NOT_A_MEMBER) that lists the capability under
17
+ * capabilities/<name>/oats.json (E_CAPABILITY_MISSING); a private capability
18
+ * is usable only from its own repo (E_CAPABILITY_PRIVATE). It NEVER looks
19
+ * inside the repo's oats-package/ (non-collapse rule): a name that exists only
20
+ * there fails with details.hint "provided by package <id>; use from: package".
21
+ * - `from: package` → the lock's packageProviding(name) (E_PACKAGE_MISSING), approved
22
+ * (E_PACKAGE_UNAPPROVED). It NEVER looks at member capabilities, even when the
23
+ * package's repo is a member.
24
+ *
25
+ * Composition order (soul wins; `off` removes):
26
+ * workspace.defaults.{knowledge,messaging,tasks} (slot defaults; a soul `none` drops them)
27
+ * ⊕ workspace.defaults.capabilities ⊕ workspace.defaults.byTeam[soul.team].capabilities
28
+ * ⊕ soul.capabilities
29
+ *
30
+ * Payloads (decision 14), later wins on scalars/arrays, objects deep-merge:
31
+ * workspace.messaging (messaging slot only) ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
32
+ *
33
+ * This module shells out to nothing. Remote access is injected (`remote`, default
34
+ * lib/remote.mjs; `remoteOptions` threaded into every call). `resolveSoul` is
35
+ * `async` because member skill trees and package capability manifests are read
36
+ * over the remote; everything else is pure and exported for direct testing.
37
+ */
38
+ import { createHash } from "node:crypto";
39
+ import { posix } from "node:path";
40
+ import { oatsError as baseOatsError } from "./errors.mjs";
41
+ import * as defaultRemote from "./remote.mjs";
42
+ import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
43
+
44
+ export const RESOLUTION_API = 1;
45
+ export const SLOTS = Object.freeze(["knowledge", "messaging", "tasks"]);
46
+ const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
47
+
48
+ /* ───────────────────────────── helpers ────────────────────────────────── */
49
+
50
+ /** oatsError with `details` readable as both e.provenance (today) and e.details (the contract). */
51
+ function fail(code, message, details) {
52
+ const e = baseOatsError(code, message, details);
53
+ if (details !== undefined) e.details = details;
54
+ return e;
55
+ }
56
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
57
+ const show = (v) => JSON.stringify(v);
58
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
59
+ const short = (oid) => String(oid || "").slice(0, 12);
60
+
61
+ /** Keys a payload may never carry: `__proto__` as an own key (yaml/JSON produce it) would set the
62
+ * prototype of the merged object — a value invisible to JSON/canonicalJson (so the recorded payload
63
+ * and the revision look clean) yet visible to every in-process property read. Refused, never skipped. */
64
+ const POISON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
65
+ function assertPayloadKeys(value, path) {
66
+ if (Array.isArray(value)) { value.forEach((v, i) => assertPayloadKeys(v, `${path}/${i}`)); return; }
67
+ if (!isObject(value)) return;
68
+ for (const k of Object.keys(value)) {
69
+ if (POISON_KEYS.has(k)) throw fail("E_WORKSPACE_SCHEMA", `provider payload key ${show(k)} at ${path || "/"} is refused (it would poison the merged payload's prototype)`, { path: `${path}/${k}`, key: k, reason: "poison-key" });
70
+ assertPayloadKeys(value[k], `${path}/${k}`);
71
+ }
72
+ }
73
+
74
+ /** Deep-merge provider payloads: plain objects merge recursively; arrays and scalars — later wins.
75
+ * Payload keys `__proto__` / `constructor` / `prototype` (at any depth) → E_WORKSPACE_SCHEMA. */
76
+ export function mergePayload(...layers) {
77
+ let out = {};
78
+ for (const layer of layers) {
79
+ if (layer === undefined || layer === null) continue;
80
+ if (!isObject(layer)) throw new TypeError(`a provider payload must be an object, got ${typeof layer}`);
81
+ assertPayloadKeys(layer, "");
82
+ out = mergeInto(out, layer);
83
+ }
84
+ return out;
85
+ }
86
+ function mergeInto(base, over) {
87
+ const out = { ...base };
88
+ for (const [k, v] of Object.entries(over)) {
89
+ if (v === undefined) continue;
90
+ if (POISON_KEYS.has(k)) throw fail("E_WORKSPACE_SCHEMA", `provider payload key ${show(k)} is refused`, { key: k, reason: "poison-key" });
91
+ out[k] = isObject(v) && isObject(out[k]) ? mergeInto(out[k], v) : clone(v);
92
+ }
93
+ return out;
94
+ }
95
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
96
+
97
+ /** `byTeam` is RESERVED (decision 23): it addresses a per-team payload and is legal only at the top level of
98
+ * workspace.messaging, where the resolver merges base ⊕ byTeam[soul.team] and strips it. In any other payload
99
+ * layer — a soul's slot payload, local.settings[cap], spawn.providers[cap] — it would reach the provider
100
+ * verbatim; refused at the layer's top level with E_WORKSPACE_SCHEMA reason "reserved-key" and the path named. */
101
+ const RESERVED_KEY = "byTeam";
102
+ function assertNoReservedKey(value, path) {
103
+ if (isObject(value) && Object.hasOwn(value, RESERVED_KEY)) {
104
+ throw fail("E_WORKSPACE_SCHEMA", `${path}/${RESERVED_KEY}: ${show(RESERVED_KEY)} is reserved — it is legal only at the top level of the workspace file's messaging: payload (decision 23)`, { path: `${path}/${RESERVED_KEY}`, key: RESERVED_KEY, reason: "reserved-key" });
105
+ }
106
+ }
107
+
108
+ /** Canonical JSON: object keys sorted (recursively), arrays in order, no whitespace. */
109
+ export function canonicalJson(value) {
110
+ if (value === undefined) return "null";
111
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
112
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
113
+ return `{${Object.keys(value).sort(byCodepoint).map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
114
+ }
115
+
116
+ /** revision = sha256(canonical JSON)[0:24] — the fingerprint a spawn decision embeds. */
117
+ export function revisionOf(body) {
118
+ return createHash("sha256").update(canonicalJson(body)).digest("hex").slice(0, 24);
119
+ }
120
+
121
+ function deepFreeze(value) {
122
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
123
+ Object.freeze(value);
124
+ for (const v of Object.values(value)) deepFreeze(v);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /** A repo ref lib/remote.mjs can open for a canonical key ("<host>/<path>" | "local/<abs>"). */
130
+ export function refForKey(key) {
131
+ if (typeof key !== "string" || !key) throw fail("E_REPO_REF", `not a repo key: ${show(key)}`, { key });
132
+ return key.startsWith("local/") ? key.slice("local/".length) : `git:${key}`;
133
+ }
134
+
135
+ function remoteOf({ remote, remoteOptions } = {}) {
136
+ const r = remote ?? defaultRemote;
137
+ for (const name of ["parseRepoRef", "readRemoteFile", "listRemoteTree"]) {
138
+ if (typeof r?.[name] !== "function") throw new TypeError(`remote must provide ${name}() (module contract §1)`);
139
+ }
140
+ return bindRemote(r, remoteOptions);
141
+ }
142
+
143
+ /* ───────────────────────────── semver-ish ranges ──────────────────────── */
144
+
145
+ const VERSION_RE = /^v?(\d+)(?:\.(\d+|x|X|\*))?(?:\.(\d+|x|X|\*))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
146
+
147
+ /** "v2.1.3-rc.1+build" → { major, minor, patch, prerelease: ["rc", 1] } | null. */
148
+ export function parseVersion(text) {
149
+ const m = typeof text === "string" && VERSION_RE.exec(text.trim());
150
+ if (!m) return null;
151
+ const num = (s) => (s === undefined || /^[xX*]$/.test(s) ? 0 : Number(s));
152
+ return {
153
+ major: Number(m[1]), minor: num(m[2]), patch: num(m[3]),
154
+ prerelease: m[4] ? m[4].split(".").map((p) => (/^\d+$/.test(p) ? Number(p) : p)) : [],
155
+ };
156
+ }
157
+ function compareVersions(a, b) {
158
+ for (const k of ["major", "minor", "patch"]) if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
159
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
160
+ if (a.prerelease.length === 0) return 1; // a release outranks any prerelease of the same triple
161
+ if (b.prerelease.length === 0) return -1;
162
+ const n = Math.max(a.prerelease.length, b.prerelease.length);
163
+ for (let i = 0; i < n; i++) {
164
+ const x = a.prerelease[i], y = b.prerelease[i];
165
+ if (x === undefined) return -1;
166
+ if (y === undefined) return 1;
167
+ if (x === y) continue;
168
+ if (typeof x === "number" && typeof y === "number") return x < y ? -1 : 1;
169
+ if (typeof x === "number") return -1; // numeric identifiers rank below alphanumeric ones
170
+ if (typeof y === "number") return 1;
171
+ return x < y ? -1 : 1;
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ const COMPARATOR_RE = /^(>=|<=|>|<|=|\^|~)?(.+)$/;
177
+ /** One comparator → [{ op, version }] (a partial or ^/~ form expands to a lower and upper bound). */
178
+ function expandComparator(token) {
179
+ if (token === "*" || token === "" || /^[xX]$/.test(token)) return [];
180
+ const m = COMPARATOR_RE.exec(token);
181
+ if (!m) return null;
182
+ const op = m[1] || "";
183
+ const raw = m[2];
184
+ const parts = VERSION_RE.exec(raw.replace(/^v/, ""));
185
+ if (!parts) return null;
186
+ const v = parseVersion(raw);
187
+ const hasMinor = parts[2] !== undefined && !/^[xX*]$/.test(parts[2]);
188
+ const hasPatch = parts[3] !== undefined && !/^[xX*]$/.test(parts[3]);
189
+ const bump = (major, minor, patch) => ({ major, minor, patch, prerelease: [] });
190
+ const lower = { op: ">=", version: v };
191
+ if (op === "^") {
192
+ const upper = v.major > 0 || !hasMinor ? bump(v.major + 1, 0, 0) : v.minor > 0 || !hasPatch ? bump(0, v.minor + 1, 0) : bump(0, 0, v.patch + 1);
193
+ return [lower, { op: "<", version: upper }];
194
+ }
195
+ if (op === "~") return [lower, { op: "<", version: hasMinor ? bump(v.major, v.minor + 1, 0) : bump(v.major + 1, 0, 0) }];
196
+ if (op === "" || op === "=") {
197
+ if (hasMinor && hasPatch) return [{ op: "=", version: v }];
198
+ return [lower, { op: "<", version: hasMinor ? bump(v.major, v.minor + 1, 0) : bump(v.major + 1, 0, 0) }];
199
+ }
200
+ if (!hasMinor || !hasPatch) {
201
+ // >=1.2 → >=1.2.0 ; <1.2 → <1.2.0 ; >1.2 → >=1.3.0 ; <=1.2 → <1.3.0
202
+ const next = hasMinor ? bump(v.major, v.minor + 1, 0) : bump(v.major + 1, 0, 0);
203
+ if (op === ">") return [{ op: ">=", version: next }];
204
+ if (op === "<=") return [{ op: "<", version: next }];
205
+ }
206
+ return [{ op, version: v }];
207
+ }
208
+ const OPS = {
209
+ ">=": (c) => c >= 0, ">": (c) => c > 0, "<=": (c) => c <= 0, "<": (c) => c < 0, "=": (c) => c === 0,
210
+ };
211
+
212
+ /**
213
+ * Does `version` satisfy `range`? Supports `>=`, `>`, `<=`, `<`, `=`, `^`, `~`, exact, partial
214
+ * (`1.2`), `*`, whitespace-AND and `||`-OR. No dependency. Throws E_COMPATIBILITY { why: "range" }
215
+ * for an unparseable range and { why: "version" } for an unparseable version.
216
+ */
217
+ export function satisfiesRange(version, range) {
218
+ const v = parseVersion(version);
219
+ if (!v) throw fail("E_COMPATIBILITY", `cannot compare ${show(version)}: not a version`, { why: "version", version, range });
220
+ if (typeof range !== "string") throw fail("E_COMPATIBILITY", `not a version range: ${show(range)}`, { why: "range", range });
221
+ const alternatives = range.split("||").map((alt) => alt.trim());
222
+ for (const alt of alternatives) {
223
+ const tokens = alt.length ? alt.split(/\s+/) : [""];
224
+ const comparators = [];
225
+ for (const token of tokens) {
226
+ const expanded = expandComparator(token);
227
+ if (expanded === null) throw fail("E_COMPATIBILITY", `not a version range: ${show(range)} (at ${show(token)})`, { why: "range", range, token });
228
+ comparators.push(...expanded);
229
+ }
230
+ if (comparators.every((c) => OPS[c.op](compareVersions(v, c.version)))) return true;
231
+ }
232
+ return false;
233
+ }
234
+
235
+ /* ───────────────────────────── composition ────────────────────────────── */
236
+
237
+ const choiceOf = (value, path, via) => {
238
+ if (value === "off") return "off";
239
+ if (isObject(value) && typeof value.from === "string" && value.from) {
240
+ // `here` has a referent only in a soul (its own repo); a workspace default would mean a different repo per soul.
241
+ if (value.from === "here" && via !== "soul") throw fail("E_WORKSPACE_SCHEMA", `${path}/from: "here" is only meaningful in a soul; a workspace default names a repo key or package`, { path: `${path}/from`, value: value.from });
242
+ return { from: value.from };
243
+ }
244
+ throw fail("E_WORKSPACE_SCHEMA", `${path} must be { from: <location> } or "off", got ${show(value)}`, { path, value });
245
+ };
246
+
247
+ /**
248
+ * The ordered capability map of a soul BEFORE any lookup:
249
+ * slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ defaults.byTeam[team] ⊕ soul.capabilities
250
+ * → [{ name, from, via }] sorted by name; `off` removes the entry from every lower layer.
251
+ * `via` is one of "defaults.<slot>" | "defaults.capabilities" | "defaults.byTeam.<team>" | "soul".
252
+ */
253
+ export function composeCapabilities(workspace, soulDefinition, { team = null } = {}) {
254
+ const map = new Map();
255
+ const apply = (entries, via, path) => {
256
+ for (const [name, value] of Object.entries(entries || {})) {
257
+ const choice = choiceOf(value, `${path}/${name}`, via);
258
+ if (choice === "off") map.delete(name);
259
+ else map.set(name, { name, from: choice.from, via });
260
+ }
261
+ };
262
+ const defaults = isObject(workspace?.defaults) ? workspace.defaults : {};
263
+ for (const slot of SLOTS) {
264
+ const d = defaults[slot];
265
+ if (soulDefinition?.[slot] === "none" || d === "none" || !isObject(d)) continue;
266
+ const names = Object.keys(d);
267
+ if (names.length > 1) throw fail("E_WORKSPACE_SCHEMA", `defaults.${slot} names ${names.length} capabilities; a slot default names at most one`, { path: `/defaults/${slot}`, names });
268
+ apply(d, `defaults.${slot}`, `/defaults/${slot}`);
269
+ }
270
+ apply(defaults.capabilities, "defaults.capabilities", "/defaults/capabilities");
271
+ if (team !== null && isObject(defaults.byTeam) && isObject(defaults.byTeam[team])) {
272
+ apply(defaults.byTeam[team].capabilities, `defaults.byTeam.${team}`, `/defaults/byTeam/${team}/capabilities`);
273
+ }
274
+ apply(soulDefinition?.capabilities, "soul", "/capabilities");
275
+ return [...map.values()].sort((a, b) => byCodepoint(a.name, b.name));
276
+ }
277
+
278
+ /* ───────────────────────────── lookups ────────────────────────────────── */
279
+
280
+ function memberRow(discovery, repoKey) {
281
+ return (discovery?.members || []).find((m) => m.key === repoKey) || null;
282
+ }
283
+
284
+ /** The ref the workspace lists a member under (keeps the operator's spelling: ssh vs https); else from the key. */
285
+ function memberRef(discovery, remote, repoKey) {
286
+ for (const ref of discovery?.workspace?.members || []) {
287
+ try { if (remote.parseRepoRef(ref).key === repoKey) return ref; } catch { /* validated upstream */ }
288
+ }
289
+ return refForKey(repoKey);
290
+ }
291
+
292
+ /** Resolve `from: <repo>` (or `here`) against member capabilities only — the non-collapse rule. */
293
+ function lookupMember(discovery, soul, name, from, via, lock) {
294
+ const repoKey = from === "here" ? soul.repoKey : from;
295
+ const row = memberRow(discovery, repoKey);
296
+ const where = { capability: name, from, repoKey, soul: soul.name, via };
297
+ const standaloneOwn = discovery?.standalone === true && repoKey === soul.repoKey && row !== null;
298
+ if (!row) {
299
+ const ws = discovery?.key ? ` ${discovery.key}` : "";
300
+ throw fail("E_NOT_A_MEMBER", `${name}: ${from === "here" ? `here (${repoKey})` : repoKey} is not a member of the workspace${ws} — a capability comes from a confirmed member or from a package`, { ...where, reason: "not-listed" });
301
+ }
302
+ if (!row.confirmed && !standaloneOwn) {
303
+ throw fail("E_NOT_A_MEMBER", `${name}: ${repoKey} is listed but not confirmed (${row.reason || "unconfirmed"}${row.detail ? `: ${row.detail}` : ""})`, { ...where, reason: row.reason || "unconfirmed", detail: row.detail });
304
+ }
305
+ const cap = (row.capabilities || []).find((c) => c.name === name) || null;
306
+ if (!cap) {
307
+ const details = { ...where, commit: row.commit };
308
+ // The name may live in a package tier: say so, but never resolve it from here (decision 19).
309
+ const providing = safePackageProviding(lock, name);
310
+ let hint = null;
311
+ if (providing) hint = `provided by package ${providing.id}; use from: package`;
312
+ else if (row.publishes?.package) hint = `${repoKey} publishes package ${row.publishes.package}; a capability under oats-package/ is package-tier — pin the package in packages: and use from: package`;
313
+ throw fail("E_CAPABILITY_MISSING", `${name}: ${repoKey}@${short(row.commit)} has no capabilities/${name}/oats.json${hint ? ` (${hint})` : ""}`, hint ? { ...details, hint } : details);
314
+ }
315
+ if (cap.private && cap.repoKey !== soul.repoKey) {
316
+ throw fail("E_CAPABILITY_PRIVATE", `${name}: ${repoKey} marks it private; it is usable only by souls of ${repoKey} (this soul lives in ${soul.repoKey})`, { ...where, owner: repoKey, soulRepo: soul.repoKey });
317
+ }
318
+ return { row, cap };
319
+ }
320
+ function safePackageProviding(lock, name) {
321
+ if (!isObject(lock)) return null;
322
+ try { return packageProviding(lock, name); } catch { return null; }
323
+ }
324
+
325
+ /** Resolve `from: package` through the lock only — never through member capabilities. */
326
+ function lookupPackage(lock, name, via, soul) {
327
+ const where = { capability: name, from: "package", soul: soul.name, via };
328
+ if (!isObject(lock) || !isObject(lock.packages)) throw fail("E_PACKAGE_MISSING", `${name}: from: package needs the workspace lock (oats-lock.json v3) — run \`oats sync\``, { ...where, reason: "no-lock" });
329
+ const providing = packageProviding(lock, name);
330
+ if (!providing) throw fail("E_PACKAGE_MISSING", `${name}: no locked package provides it — add the package to packages: and run \`oats sync\``, { ...where, locked: Object.keys(lock.packages).sort() });
331
+ if (!providing.entry.approved || !isObject(providing.entry.approved) || typeof providing.entry.approved.executables !== "string" || !/^sha256-[0-9a-f]{64}$/.test(providing.entry.approved.executables)) {
332
+ throw fail("E_PACKAGE_UNAPPROVED", `${name}: package ${providing.id} v${providing.entry.version} (${short(providing.entry.commit)}) is not approved — review its executables and approve once per version`, { ...where, id: providing.id, version: providing.entry.version, commit: providing.entry.commit });
333
+ }
334
+ return providing;
335
+ }
336
+
337
+ /** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
338
+ export function packageRef(id, entry, catalog, remote) {
339
+ const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
340
+ if (entry.source === `catalog:${id}`) {
341
+ if (typeof entry.url === "string" && entry.url) return entry.url;
342
+ const c = cat[id];
343
+ if (isObject(c) && typeof c.url === "string") return c.url;
344
+ throw fail("E_PACKAGE_MISSING", `${id}: locked from the catalog, but neither the lock (url) nor a catalog entry says which repo to read it from — run \`oats sync\` (lock v3 records url) or pass { catalog } to resolveSoul`, { id, source: entry.source, reason: "no-catalog" });
345
+ }
346
+ const m = /^git:(.+)@([^@]+)$/.exec(entry.source);
347
+ if (!m) throw fail("E_LOCK_SCHEMA", `${id}: lock source ${show(entry.source)} is neither catalog:<id> nor git:<key>@<ref>`, { id, source: entry.source });
348
+ const key = m[1];
349
+ const ref = refForKey(key);
350
+ remote.parseRepoRef(ref); // E_REPO_REF for a smuggled key
351
+ return ref;
352
+ }
353
+
354
+ /* ───────────────────────────── skills ─────────────────────────────────── */
355
+
356
+ const SAFE_REL = (rel) => typeof rel === "string" && rel && !posix.isAbsolute(rel) && !/^[\\/]/.test(rel) && !/\0/.test(rel) && !rel.split(/[\\/]/).some((p) => p === "..");
357
+
358
+ /** Normalize a manifest-declared relative path ("./skills/x/" → "skills/x"); null when unsafe. */
359
+ function declaredPath(rel) {
360
+ if (!SAFE_REL(rel)) return null;
361
+ const n = posix.normalize(rel).replace(/\/+$/, "");
362
+ return n === "." || n === "" || n.startsWith("../") ? null : n;
363
+ }
364
+
365
+ /**
366
+ * Skills declared by a manifest: each `skills[]` entry is a directory under the capability that either
367
+ * IS a skill (holds SKILL.md) or holds skill directories (<entry>/<skill>/SKILL.md) — the same reading
368
+ * the 0.24 kernel applied. `listing` is a listRemoteTree()-shaped array RELATIVE TO THE ENTRY.
369
+ * → [{ name, path }] with path relative to the capability directory.
370
+ */
371
+ export function skillsInListing(declared, listing) {
372
+ const out = [];
373
+ const blobs = new Set((listing || []).filter((e) => e.type === "blob").map((e) => e.path));
374
+ if (blobs.has("SKILL.md")) return [{ name: posix.basename(declared), path: declared }];
375
+ for (const p of [...blobs].sort(byCodepoint)) {
376
+ const m = /^([^/]+)\/SKILL\.md$/.exec(p);
377
+ if (m) out.push({ name: m[1], path: posix.join(declared, m[1]) });
378
+ }
379
+ return out;
380
+ }
381
+
382
+ /** Enumerate the skills of one module over the remote (or a discovery-provided listing). */
383
+ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, moduleName, missing }) {
384
+ const declaredList = Array.isArray(manifest.skills) ? manifest.skills : [];
385
+ const skills = [];
386
+ for (const raw of declaredList) {
387
+ const declared = declaredPath(raw);
388
+ if (!declared) throw missing(raw, "unsafe", `declares skills entry ${show(raw)}, which is not a relative path inside the capability`);
389
+ let entries;
390
+ if (Array.isArray(listing)) {
391
+ // Discovery already listed the capability directory (relative to it): project onto this entry.
392
+ entries = listing.filter((e) => e.path === declared || e.path.startsWith(`${declared}/`)).map((e) => ({ ...e, path: e.path === declared ? "" : e.path.slice(declared.length + 1) })).filter((e) => e.path);
393
+ } else {
394
+ entries = await remote.listRemoteTree(ref, commit, posix.join(dir, declared), { depth: 2 });
395
+ }
396
+ const found = skillsInListing(declared, entries);
397
+ if (found.length === 0) throw missing(raw, "skill-missing", `declares skills entry ${show(raw)} but no SKILL.md is there (neither ${declared}/SKILL.md nor ${declared}/*/SKILL.md)`);
398
+ for (const s of found) skills.push({ module: moduleName, name: s.name, path: s.path });
399
+ }
400
+ return skills;
401
+ }
402
+
403
+ /* ───────────────────────────── resolveSoul ────────────────────────────── */
404
+
405
+ /**
406
+ * From a discovered soul to an immutable Resolution.
407
+ *
408
+ * discovery: discoverWorkspace(...) output (or standaloneRepo(...) output — from:here only).
409
+ * soulEntry: one of discovery.members[].souls[] or discovery.external[].soul
410
+ * ({ name, path, repoKey, commit, team, private, definition }).
411
+ * options: { local, lock, spawn = { providers? }, catalog, remote, remoteOptions }
412
+ * `catalog` (package-catalog.json shape) tells which repo a `catalog:<id>` lock entry is read from.
413
+ *
414
+ * → Resolution { resolutionApi: 1, soul, modules[], slots, payloads, skills[], injects[], revision } — deep-frozen.
415
+ * Extra fields beyond the contract (recorded for materialize): module.dir (capability dir, repo-relative)
416
+ * and from.repoKey on package modules.
417
+ */
418
+ export async function resolveSoul(discovery, soulEntry, { local = null, lock = null, spawn = {}, catalog = null, remote: injected, remoteOptions } = {}) {
419
+ if (!isObject(soulEntry) || typeof soulEntry.name !== "string" || typeof soulEntry.repoKey !== "string") {
420
+ throw new TypeError("resolveSoul: soulEntry must be a discovery SoulEntry { name, path, repoKey, commit, team, private, definition }");
421
+ }
422
+ if (!isObject(spawn)) throw new TypeError("resolveSoul: spawn must be an object");
423
+ if (lock !== null && lock !== undefined) validateLock(lock); // E_LOCK_SCHEMA: a lock passed in memory meets the same bar as one read from disk
424
+ const remote = remoteOf({ remote: injected, remoteOptions });
425
+ const workspace = isObject(discovery?.workspace) ? discovery.workspace : null;
426
+ assertSoulDiscovered(discovery, soulEntry);
427
+ const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
428
+ const team = typeof soulEntry.team === "string" ? soulEntry.team : null;
429
+ const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, team, path: soulEntry.path ?? null };
430
+
431
+ // Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
432
+ const declared = discovery?.standalone === true
433
+ ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { team })
434
+ : composeCapabilities(workspace, definition, { team });
435
+
436
+ const modules = [];
437
+ const skills = [];
438
+ const injects = [];
439
+ for (const { name, from, via } of declared) {
440
+ let module;
441
+ if (from === "package") {
442
+ const { id, entry } = lookupPackage(lock, name, via, soul);
443
+ const ref = packageRef(id, entry, catalog, remote);
444
+ const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
445
+ const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
446
+ const cap = capabilities.find((c) => c.name === name);
447
+ if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
448
+ module = {
449
+ name, from: { kind: "package", package: id, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
450
+ manifest: clone(cap.manifest), layer: layerOf(cap.manifest), private: cap.manifest.private === true, dir: cap.dir,
451
+ };
452
+ const missing = (raw, why, text) => fail("E_PACKAGE_MANIFEST", `${name} (package ${id} v${entry.version}) ${text}`, { ...details, skill: raw, why });
453
+ skills.push(...await enumerateSkills({ remote, ref, commit: entry.commit, dir: cap.dir, manifest: cap.manifest, moduleName: name, missing }));
454
+ } else {
455
+ const { row, cap } = lookupMember(discovery, soul, name, from, via, lock);
456
+ const ref = memberRef(discovery, remote, cap.repoKey);
457
+ module = {
458
+ name, from: { kind: "member", repoKey: cap.repoKey, commit: cap.commit ?? row.commit },
459
+ manifest: clone(cap.manifest), layer: layerOf(cap.manifest), private: cap.private === true, dir: cap.path,
460
+ };
461
+ const missing = (raw, why, text) => fail("E_CAPABILITY_MISSING", `${name} (${cap.repoKey}@${short(module.from.commit)}) ${text}`, { capability: name, repoKey: cap.repoKey, commit: module.from.commit, skill: raw, why });
462
+ skills.push(...await enumerateSkills({ remote, ref, commit: module.from.commit, dir: cap.path, manifest: cap.manifest, listing: cap.listing, moduleName: name, missing }));
463
+ }
464
+ if (typeof module.manifest.inject === "string" && module.manifest.inject) {
465
+ const inject = declaredPath(module.manifest.inject);
466
+ if (!inject) throw fail(module.from.kind === "package" ? "E_PACKAGE_MANIFEST" : "E_CAPABILITY_MISSING", `${name} declares inject ${show(module.manifest.inject)}, which is not a relative path inside the capability`, { capability: name, inject: module.manifest.inject, why: "unsafe" });
467
+ injects.push({ module: name, path: inject });
468
+ }
469
+ modules.push(module);
470
+ }
471
+
472
+ // Slots (decision: a resolved capability whose manifest has layer X fills slot X; two → conflict; soul `none` empties).
473
+ const slots = { knowledge: null, messaging: null, tasks: null };
474
+ const viaOf = new Map(declared.map((d) => [d.name, d.via]));
475
+ for (const m of modules) {
476
+ const via = viaOf.get(m.name);
477
+ const slotDefault = typeof via === "string" && via.startsWith("defaults.") && SLOTS.includes(via.slice("defaults.".length)) ? via.slice("defaults.".length) : null;
478
+ // A slot default must fill THAT slot: its manifest's layer is the slot (contract §3 "else workspace default").
479
+ if (slotDefault && m.layer !== slotDefault) {
480
+ throw fail("E_SLOT_CONFLICT", `slot ${slotDefault}: defaults.${slotDefault} names ${m.name}, whose manifest declares layer ${m.layer ? show(m.layer) : "none"} — a slot default must be a ${slotDefault}-layer capability`, { slot: slotDefault, modules: [m.name], soul: soul.name, reason: "layer-mismatch", layer: m.layer });
481
+ }
482
+ if (!m.layer) continue;
483
+ if (slots[m.layer]) throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: both ${slots[m.layer]} and ${m.name} declare layer ${m.layer}; a soul fills each slot with at most one capability`, { slot: m.layer, modules: [slots[m.layer], m.name], soul: soul.name });
484
+ if (definition[m.layer] === "none") throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: the soul says ${m.layer}: none but names ${m.name}, which declares layer ${m.layer}`, { slot: m.layer, modules: [m.name], soul: soul.name, reason: "none" });
485
+ slots[m.layer] = m.name;
486
+ }
487
+
488
+ // Duplicate skill names within the composed set (decision 16).
489
+ const seenSkills = new Map();
490
+ for (const s of skills) {
491
+ if (seenSkills.has(s.name)) {
492
+ const prior = seenSkills.get(s.name);
493
+ throw fail("E_SKILL_DUPLICATE", `skill ${show(s.name)} is contributed by both ${prior.module} (${prior.path}) and ${s.module} (${s.path})`, { name: s.name, modules: [prior.module, s.module], paths: [prior.path, s.path], soul: soul.name });
494
+ }
495
+ seenSkills.set(s.name, s);
496
+ }
497
+
498
+ // Payloads (decision 14): workspace.messaging (messaging slot) ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap].
499
+ const settings = isObject(local?.settings) ? local.settings : {};
500
+ const providers = isObject(spawn.providers) ? spawn.providers : {};
501
+ const moduleNames = new Set(modules.map((m) => m.name));
502
+ // Own keys only (Object.entries): a provider name that is merely inherited by `providers` is not a request.
503
+ for (const [cap, value] of Object.entries(providers)) {
504
+ if (!isObject(value)) throw new TypeError(`spawn.providers.${cap} must be an object`);
505
+ if (POISON_KEYS.has(cap)) throw fail("E_WORKSPACE_SCHEMA", `spawn.providers.${cap}: capability name ${show(cap)} is refused (it would poison the payload's prototype)`, { path: `/spawn/providers/${cap}`, key: cap, reason: "poison-key" });
506
+ assertNoReservedKey(value, `/spawn/providers/${cap}`);
507
+ if (!moduleNames.has(cap)) throw fail("E_CAPABILITY_MISSING", `--provider ${cap}: the soul ${soul.name} does not resolve a capability named ${show(cap)} (modules: ${[...moduleNames].sort().join(", ") || "none"})`, { capability: cap, soul: soul.name, hint: "a provider payload targets one of the soul's resolved capabilities", modules: [...moduleNames].sort() });
508
+ }
509
+ const payloads = {};
510
+ // The soul's slot payloads are checked whether or not the slot resolves to a module: a reserved key is a
511
+ // schema fault of the soul, not of the spawn that happened to fill the slot.
512
+ for (const slot of SLOTS) if (isObject(definition[slot])) assertNoReservedKey(definition[slot], `/${slot}`);
513
+ for (const m of modules) {
514
+ const layers = [];
515
+ if (m.layer === "messaging" && isObject(workspace?.messaging)) {
516
+ // Decision 23: base ⊕ byTeam[soul.team]; `byTeam` never reaches the provider (nor may a team's own payload nest one).
517
+ const { byTeam, ...base } = workspace.messaging;
518
+ layers.push(base);
519
+ if (team !== null && isObject(byTeam) && isObject(byTeam[team])) { assertNoReservedKey(byTeam[team], `/messaging/byTeam/${team}`); layers.push(byTeam[team]); }
520
+ }
521
+ if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); layers.push(definition[m.layer]); }
522
+ if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); layers.push(settings[m.name]); }
523
+ if (Object.hasOwn(providers, m.name) && isObject(providers[m.name])) layers.push(providers[m.name]);
524
+ payloads[m.name] = mergePayload(...layers);
525
+ }
526
+
527
+ // Compatibility floors (soul.compatibility) are constraints on PACKAGE versions.
528
+ for (const [cap, range] of Object.entries(isObject(definition.compatibility) ? definition.compatibility : {})) {
529
+ const m = modules.find((x) => x.name === cap);
530
+ if (!m || m.from.kind !== "package") continue;
531
+ const where = { capability: cap, package: m.from.package, version: m.from.version, range, soul: soul.name };
532
+ // A git:<repo>@<OID> package carries the OID as its version: unversioned, so no floor can be met (even an all-digit OID).
533
+ if (/^[0-9a-f]{40}$/i.test(String(m.from.version)) || !parseVersion(m.from.version)) {
534
+ throw fail("E_COMPATIBILITY", `${cap}: package ${m.from.package} is pinned at ${show(m.from.version)}, which is not a version — the soul's floor ${show(range)} cannot be checked; pin a tagged version in packages.${m.from.package}`, { ...where, why: "unversioned" });
535
+ }
536
+ let ok;
537
+ try { ok = satisfiesRange(m.from.version, range); }
538
+ catch (e) { if (e?.code === "E_COMPATIBILITY") throw fail("E_COMPATIBILITY", `${cap}: ${e.message}`, { ...where, ...(e.details || {}) }); throw e; }
539
+ if (!ok) {
540
+ throw fail("E_COMPATIBILITY", `${cap}: package ${m.from.package} is pinned at v${m.from.version}, below the soul's floor ${show(range)} — bump packages.${m.from.package} in the workspace`, where);
541
+ }
542
+ }
543
+
544
+ modules.sort((a, b) => byCodepoint(a.name, b.name));
545
+ skills.sort((a, b) => byCodepoint(a.module, b.module) || byCodepoint(a.name, b.name));
546
+ injects.sort((a, b) => byCodepoint(a.module, b.module));
547
+ const body = { resolutionApi: RESOLUTION_API, soul, modules, slots, payloads, skills, injects };
548
+ return deepFreeze({ ...body, revision: revisionOf(body) });
549
+ }
550
+
551
+ function layerOf(manifest) {
552
+ return SLOTS.includes(manifest?.layer) ? manifest.layer : null;
553
+ }
554
+
555
+ /**
556
+ * Membership gate (decision 1, contract §3): the soul must be one discovery actually listed — a soul of a
557
+ * CONFIRMED member row (same repoKey, name and commit), an `external[]` soul, or (standalone) the repo's own.
558
+ * A soul of an unconfirmed member → E_MEMBERSHIP_UNCONFIRMED { repoKey, reason }; a soul of a repo the
559
+ * workspace does not list → E_NOT_A_MEMBER; a row that does not carry this soul → E_MEMBERSHIP_UNCONFIRMED { reason: "stale" }.
560
+ */
561
+ function assertSoulDiscovered(discovery, soulEntry) {
562
+ const { name, repoKey } = soulEntry;
563
+ const where = { soul: name, repoKey };
564
+ const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
565
+ if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
566
+ const row = memberRow(discovery, repoKey);
567
+ if (!row) throw fail("E_NOT_A_MEMBER", `soul ${name}: ${repoKey} is not a member of the workspace${discovery?.key ? ` ${discovery.key}` : ""} (nor an external soul) — a soul is spawned from a confirmed member or an external entry`, { ...where, reason: "not-listed" });
568
+ if (!row.confirmed && !(discovery?.standalone === true)) {
569
+ throw fail("E_MEMBERSHIP_UNCONFIRMED", `soul ${name}: ${repoKey} is listed but its membership is not confirmed (${row.reason || "unconfirmed"}${row.detail ? `: ${row.detail}` : ""}) — an unconfirmed member contributes nothing but its row`, { ...where, reason: row.reason || "unconfirmed", detail: row.detail ?? null });
570
+ }
571
+ if (!(row.souls || []).some(sameSoul)) {
572
+ throw fail("E_MEMBERSHIP_UNCONFIRMED", `soul ${name}: ${repoKey}@${short(row.commit)} does not list it at that commit — the soul entry is stale or fabricated; re-run discovery`, { ...where, reason: "stale", commit: row.commit, soulCommit: soulEntry.commit ?? null });
573
+ }
574
+ }
575
+
576
+ export { CONTRACT_DOC as RESOLVE_CONTRACT };