@awebai/oats 0.24.13 → 0.25.1

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