@awebai/oats 0.27.2 → 0.28.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 (37) hide show
  1. package/bin/oats.mjs +185 -26
  2. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
  3. package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
  4. package/capabilities/oats-okf/injects/okf.md +29 -21
  5. package/capabilities/oats-okf/lib/config.mjs +5 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +500 -0
  7. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  8. package/capabilities/oats-okf/lib/io.mjs +9 -2
  9. package/capabilities/oats-okf/lib/sources.mjs +15 -53
  10. package/capabilities/oats-okf/lib/stores.mjs +10 -7
  11. package/capabilities/oats-okf/lib/worker.mjs +9 -1
  12. package/capabilities/oats-okf/oats.json +13 -4
  13. package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  16. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  17. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  18. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  19. package/docs/desktop-cli-api.md +93 -7
  20. package/docs/oats-local.schema.json +2 -1
  21. package/docs/oats-package.schema.json +39 -0
  22. package/docs/packages.md +67 -3
  23. package/docs/release-notes/v0.28.0.md +144 -0
  24. package/docs/schedules.md +99 -1
  25. package/docs/souls-and-instances.md +7 -3
  26. package/docs/workspaces.md +8 -2
  27. package/lib/core.mjs +22 -4
  28. package/lib/instance-inspect.mjs +4 -4
  29. package/lib/instance-resolution.mjs +62 -18
  30. package/lib/materialize.mjs +13 -0
  31. package/lib/packages.mjs +90 -6
  32. package/lib/resolve.mjs +20 -2
  33. package/lib/schedule.mjs +24 -11
  34. package/lib/triggers.mjs +545 -0
  35. package/lib/workspace.mjs +80 -3
  36. package/package-catalog.json +1 -1
  37. package/package.json +1 -1
package/lib/core.mjs CHANGED
@@ -1501,6 +1501,11 @@ export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
1501
1501
  return "";
1502
1502
  }
1503
1503
  export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
1504
+ /** A triggered instance's event, inside its home's .oats/ (lib/triggers.mjs). */
1505
+ export const TRIGGER_EVENT_FILE = "trigger-event.json";
1506
+ /** A prepared soul entry's id: `<repoKey>#<name>` for a member or external soul, `package:<id>#<name>`
1507
+ * for a package soul (stable across the package's versions and independent of its repo). */
1508
+ const preparedSoulIdOf = (entry) => workspaceSoulId(typeof entry.package === "string" ? `package:${entry.package}` : entry.repoKey, entry.name);
1504
1509
  /** The soul directory an instance incarnates, as spawn recorded it (instance.json
1505
1510
  * `soulDir`): a workspace soul's per-commit copy (agents/<soul>/souls/<commit12>) or
1506
1511
  * the read-only soul inside a capability package. It is what every classic
@@ -1672,7 +1677,10 @@ function readSoul(agentDir, soulDir = soulOf(agentDir)) {
1672
1677
  }
1673
1678
  const soul = soulHarnessField(stripInternalAnnotations(parsed), p);
1674
1679
  soul._dir = agentDir;
1675
- soul.name = soul.name || basename(agentDir);
1680
+ // A package soul homes at <package>--<soul> (lib/workspace.mjs packageSoulAgentName): that
1681
+ // directory, not the soul.yaml name, is its agent name, so its instances never share a
1682
+ // member soul's name or roster row.
1683
+ soul.name = basename(agentDir).includes("--") ? basename(agentDir) : (soul.name || basename(agentDir));
1676
1684
  return soul;
1677
1685
  }
1678
1686
  export function findAgent(root, name) {
@@ -3270,14 +3278,22 @@ function* spawnBody(root, agent, o = {}) {
3270
3278
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
3271
3279
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
3272
3280
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
3273
- const preparedSoulId = o.prepared ? workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name) : undefined;
3281
+ const preparedSoulId = o.prepared ? preparedSoulIdOf(o.prepared.soulEntry) : undefined;
3282
+ // A trigger's event (lib/triggers.mjs): a private copy in the home, named to hooks and the harness
3283
+ // as OATS_TRIGGER_EVENT_FILE. Its PR title/body are never in the task: the soul reads them from here.
3284
+ let triggerEventFile = null;
3285
+ if (o.triggerEvent && typeof o.triggerEvent === "object") {
3286
+ triggerEventFile = join(home, ".oats", TRIGGER_EVENT_FILE);
3287
+ mkdirSync(dirname(triggerEventFile), { recursive: true });
3288
+ writeFileSync(triggerEventFile, JSON.stringify(o.triggerEvent, null, 2) + "\n", { mode: 0o600 });
3289
+ }
3274
3290
  // Hooks read the soul the HOME links (the per-commit directory for a workspace
3275
3291
  // soul), never the swappable agents/<name>/soul pointer: a provider that pins a
3276
3292
  // path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
3277
3293
  const hookRes = runLifecycleHooks("spawn", {
3278
3294
  home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
3279
3295
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
3280
- extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent" },
3296
+ extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent", ...(triggerEventFile ? { OATS_TRIGGER_EVENT_FILE: triggerEventFile } : {}) },
3281
3297
  });
3282
3298
  warnings.push(...hookRes.warnings);
3283
3299
  // Which capability hooks RAN (in order) and how each ended — recorded on the
@@ -3498,6 +3514,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3498
3514
  hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
3499
3515
  prompt: LAUNCH_PROMPT,
3500
3516
  };
3517
+ if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3501
3518
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
3502
3519
 
3503
3520
  // Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
@@ -3514,6 +3531,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3514
3531
  relativeTo: relation ? relativeTo : undefined,
3515
3532
  spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
3516
3533
  policy: { childSpawns: ownChildPolicy },
3534
+ ...(triggerEventFile ? { trigger: { id: o.triggerEvent.trigger, key: o.triggerEvent.key ?? null, source: o.triggerEvent.source, repo: o.triggerEvent.repo, number: o.triggerEvent.number, url: o.triggerEvent.url ?? null, event: o.triggerEvent.event, headSha: o.triggerEvent.headSha ?? null, observedAt: o.triggerEvent.observedAt ?? null, eventFile: triggerEventFile } } : {}),
3517
3535
  // K6c: a decision-bound spawn records what bound it, so a retry with the
3518
3536
  // same key replays this receipt instead of spawning again.
3519
3537
  // The FULL bound decision (placement + effective), exactly as the fence
@@ -3582,7 +3600,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3582
3600
  try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
3583
3601
  // M5/3a: the workspace's name and the deployment directory are recorded, so a home
3584
3602
  // answers them (inspect/operation run --home, OATS_WORKSPACE_NAME) without discovery.
3585
- meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))] }, layers: layerRows(o.prepared.resolution) };
3603
+ meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: preparedSoulIdOf(o.prepared.soulEntry), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))], ...(typeof o.prepared.soulEntry.package === "string" ? { name: o.prepared.soulEntry.name, qualifiedName: o.prepared.soulEntry.qualifiedName, package: { id: o.prepared.soulEntry.package, version: o.prepared.soulEntry.version, commit: o.prepared.soulEntry.commit, digest: o.prepared.soulEntry.digest, path: o.prepared.soulEntry.path } } : {}) }, layers: layerRows(o.prepared.resolution) };
3586
3604
  // Teams contract (decision 6): the eligible teams at spawn, recorded as EVIDENCE beside
3587
3605
  // `providers` (never inside that capability-keyed map). A home's hooks and operations get
3588
3606
  // the LIVE set (liveTeams); this is what they fall back to when discovery cannot answer.
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
18
+ import { agentDirOf, discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
21
21
  import { loadLocal } from "./workspace.mjs";
@@ -94,7 +94,7 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
94
94
  // The derived deployment exactly — never an oats-local.yaml found further up.
95
95
  const found = loadLocal(deployment);
96
96
  if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
97
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
97
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
98
98
  }
99
99
  catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
100
100
  }
@@ -140,13 +140,13 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
140
140
  let discovery = prepared?.discovery ?? null, soulEntry = prepared?.soulEntry ?? null;
141
141
  if (!prepared) {
142
142
  // Still name the soul (and its member) when its resolution is refused.
143
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
143
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
144
144
  soulEntry = findSoulEntry(discovery, soul);
145
145
  }
146
146
  const res = prepared?.resolution;
147
147
  // A refused resolution still names its eligible teams: the labels and the workspace are known.
148
148
  const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
149
- const cached = soulEntry?.commit ? join(deployment, "agents", soulEntry.name, "souls", String(soulEntry.commit).slice(0, 12)) : null;
149
+ const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
150
150
  return {
151
151
  kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
152
152
  subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },
@@ -28,7 +28,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
28
28
  import { tmpdir } from "node:os";
29
29
  import { spawnSync } from "node:child_process";
30
30
  import { randomBytes } from "node:crypto";
31
- import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
31
+ import { readLock, LOCK_FILE, readPackageManifests, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
32
32
  import * as defaultRemote from "./remote.mjs";
33
33
  import { parseRepoRef } from "./remote.mjs";
34
34
 
@@ -77,30 +77,58 @@ export function parseProviderFlags(pairs) {
77
77
  return JSON.parse(JSON.stringify(out));
78
78
  }
79
79
 
80
- /** Find the soul named `name` in a discovery: confirmed members first (by
81
- * `repo/name` or bare name when unique), then external souls. */
80
+ /** The qualified name of a discovered soul: `<package>/<soul>` for a package soul,
81
+ * `<member name>/<soul>` for a member's, `<source repo name>/<soul>` for an external one. */
82
+ export function qualifiedSoulName(entry) {
83
+ if (typeof entry?.package === "string") return `${entry.package}/${entry.name}`;
84
+ return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
85
+ }
86
+
87
+ /** Find the soul named `name` in a discovery: confirmed members, external souls and package
88
+ * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
89
+ * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
90
+ * or an external source, `<package>/<soul>` a package soul. */
82
91
  export function findSoulEntry(discovery, name) {
83
92
  const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
84
93
  const hits = [];
85
94
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
86
95
  // workspace could not be read); resolveSoul admits exactly that case.
87
96
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
97
+ const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
88
98
  for (const m of discovery.members || []) {
89
99
  if (!m.confirmed && m.key !== standaloneOwn) continue;
90
100
  for (const s of m.souls || []) {
91
- if (s.name !== soulPart) continue;
92
- if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
101
+ if (s.name !== soulPart || !memberMatches(m.key)) continue;
93
102
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
94
103
  }
95
104
  }
96
105
  for (const x of discovery.external || []) {
97
106
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
98
107
  }
99
- if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members or external souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key) });
100
- if (hits.length > 1) throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} exists in ${hits.length} repos; name it as <repo>/${soulPart}`, { name, repos: hits.map((h) => h.repoKey) });
108
+ for (const s of discovery.packageSouls || []) {
109
+ if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
110
+ }
111
+ if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
112
+ if (hits.length > 1) {
113
+ const qualified = hits.map(qualifiedSoulName);
114
+ throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} is ${hits.length} souls (${qualified.join(", ")}); name one of them`, { name, repos: hits.map((h) => h.repoKey), qualified });
115
+ }
116
+ if (Array.isArray(hits[0].collides)) throw err("E_SOUL_AMBIGUOUS", `package souls ${hits[0].collides.join(" and ")} would share the agent directory agents/${hits[0].agentName}/ — keep one of the packages in the workspace's packages:`, { name, agentDir: hits[0].agentName, qualified: hits[0].collides });
101
117
  return hits[0];
102
118
  }
103
119
 
120
+ /** The agents-root directory of a discovered soul: a package soul's is `<package>--<soul>`
121
+ * (packageSoulAgentName), every other soul's is its name. */
122
+ export function agentDirOf(entry) { return typeof entry?.agentName === "string" ? entry.agentName : entry?.name; }
123
+
124
+ /** `oats-local.yaml` `souls.disabled` (not run on this machine): an entry names a soul by its
125
+ * bare name (every soul of that name) or its qualified name. → the matching entry or null. */
126
+ export function disabledEntry(local, soulEntry) {
127
+ const list = Array.isArray(local?.souls?.disabled) ? local.souls.disabled : [];
128
+ const qualified = qualifiedSoulName(soulEntry);
129
+ return list.find((d) => d === soulEntry.name || d === qualified) ?? null;
130
+ }
131
+
104
132
  /**
105
133
  * A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
106
134
  * state, not frozen composition: the soul's labels are read from its repository NOW and each
@@ -117,6 +145,8 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
117
145
  const soul = meta?.workspace?.soul;
118
146
  // A capability agent's soul is its providing module's: no team labels, nothing to re-read.
119
147
  if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
148
+ // A package soul's labels are pinned with the package: the spawn-time record is its answer.
149
+ if (soul.package && typeof soul.package === "object") return recorded("package-soul");
120
150
  const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
121
151
  try {
122
152
  const found = loadLocal(deployment);
@@ -162,9 +192,13 @@ function swapSoulPointer(soulDir, target) {
162
192
  /** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
163
193
  async function fetchSoulSource(prepared, dest) {
164
194
  const e = prepared.soulEntry;
165
- const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
195
+ const ref = typeof e.ref === "string" ? e.ref : e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
166
196
  // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
167
- await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
197
+ const { digest } = await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: SOUL_ALIAS_SYMLINK });
198
+ // A package soul is what the lock recorded at sync: the same bytes at the locked commit.
199
+ if (typeof e.package === "string" && digest !== e.digest) {
200
+ throw err("E_PACKAGE_INTEGRITY", `package soul ${e.qualifiedName} v${e.version} @ ${String(e.commit).slice(0, 12)}: content digest ${digest} does not match the locked ${e.digest} — run \`oats sync\` (a lock that was edited: remove the entry and sync again)`, { package: e.package, soul: e.name, version: e.version, commit: e.commit, why: "soul-digest", locked: e.digest, observed: digest });
201
+ }
168
202
  if (!existsSync(join(dest, "soul.yaml")) || !existsSync(join(dest, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
169
203
  if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
170
204
  }
@@ -176,7 +210,7 @@ async function fetchSoulSource(prepared, dest) {
176
210
  * that temporary copy; `fetched` says whether the preview had to fetch. */
177
211
  export async function previewWorkspaceSoul(prepared, agentsRoot) {
178
212
  const e = prepared.soulEntry;
179
- const commitDir = join(agentsRoot, e.name, SOULS_DIR, commit12(e.commit));
213
+ const commitDir = join(agentsRoot, agentDirOf(e), SOULS_DIR, commit12(e.commit));
180
214
  if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
181
215
  const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
182
216
  const cleanup = () => rmSync(tmp, { recursive: true, force: true });
@@ -202,7 +236,7 @@ export async function previewWorkspaceSoul(prepared, agentsRoot) {
202
236
  * Returns the PER-COMMIT directory (what a home should link). */
203
237
  export async function ensureWorkspaceSoul(prepared, agentsRoot) {
204
238
  const e = prepared.soulEntry;
205
- const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
239
+ const agentDir = join(agentsRoot, agentDirOf(e)); const soulDir = join(agentDir, "soul");
206
240
  const soulsDir = join(agentDir, SOULS_DIR);
207
241
  const stamp = join(agentDir, SOUL_SOURCE_STAMP);
208
242
  const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
@@ -248,7 +282,8 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
248
282
  if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
249
283
  const cur = readStamp();
250
284
  if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
251
- writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
285
+ const pkg = typeof e.package === "string" ? { package: e.package, version: e.version } : {};
286
+ writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, ...pkg, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
252
287
  }
253
288
  return target;
254
289
  }
@@ -389,8 +424,10 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
389
424
  const local = found.local;
390
425
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
391
426
  const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
392
- const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
427
+ const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
393
428
  const soulEntry = findSoulEntry(discovery, soulName);
429
+ const disabled = disabledEntry(local, soulEntry);
430
+ if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
394
431
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
395
432
  return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
396
433
  }
@@ -402,6 +439,11 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
402
439
  const ACCESS_REASONS = new Set(["auth", "not-found"]);
403
440
  const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
404
441
 
442
+ /** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
443
+ export function deploymentLock(deployment) {
444
+ return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
445
+ }
446
+
405
447
  /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
406
448
  * repo must declare that workspace (oats-membership.yaml). A lone repo with no
407
449
  * backlink is not a member of anything and never becomes a capability source. */
@@ -424,21 +466,23 @@ function requireMembership(repo, ref) {
424
466
  * readable in this access context (auth / not-found).
425
467
  * A transient failure reading the host (network, timeout) is rethrown as-is.
426
468
  */
427
- export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
469
+ export async function discoverOrStandalone(local, { lock, deployment, remoteOptions, remote } = {}) {
428
470
  const ro = { remoteOptions, remote };
471
+ // Package souls are listed from the deployment's lock (the `lock` given, else `<deployment>/oats-lock.json`).
472
+ if (lock === undefined) lock = typeof deployment === "string" ? deploymentLock(deployment) : null;
429
473
  if (typeof local.standalone === "string" && local.standalone) {
430
474
  const repo = await discoverRepo(local.standalone, ro);
431
475
  requireMembership(repo, local.standalone);
432
476
  return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
433
477
  }
434
- try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
478
+ try { return await discoverWorkspace(local.workspace, { local, lock, ...ro }); }
435
479
  catch (e) {
436
480
  if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
437
481
  // The ref is a repository without oats-workspace.yaml: is it a member whose
438
482
  // workspace we cannot read? Then the standalone view is what the operator gets.
439
483
  const repo = await discoverRepo(local.workspace, ro);
440
484
  if (!repo.membership) throw e;
441
- try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
485
+ try { return await discoverWorkspace(repo.membership.workspace, { local, lock, ...ro }); }
442
486
  catch (inner) {
443
487
  // Only an ACCESS failure on the host means "standalone"; anything else
444
488
  // (network, timeout, a broken workspace file) is the caller's to see.
@@ -499,7 +543,7 @@ export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg
499
543
  const local = found.local;
500
544
  const deployment = dirname(found.path);
501
545
  const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
502
- const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
546
+ const view = discovery ?? await discoverOrStandalone(local, { lock, remoteOptions });
503
547
  const cap = agent.capability;
504
548
  let module, payload, origins, localModules = null;
505
549
  const anchor = pkg ? null : agent._manifestSource;
@@ -652,7 +696,7 @@ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOp
652
696
  const found = loadLocal(contextDir);
653
697
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
654
698
  const remote = defaultRemote;
655
- const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
699
+ const view = discovery === undefined ? await discoverOrStandalone(found.local, { deployment, remoteOptions }) : discovery;
656
700
  // A confirmed member's own (non-private) capability first: membership is its trust
657
701
  // decision, exactly as for a soul's `from: <member>` module.
658
702
  for (const row of view?.members || []) {
@@ -584,12 +584,25 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
584
584
  * (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
585
585
  * by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
586
586
  * not reported missing. Returns null for a home without a recorded workspace soul (classic).
587
+ * A PACKAGE soul (`workspace.soul.package`) is compared with the discovery's package souls: the
588
+ * pin moved → "moved"; the package no longer locked/declared → "missing" ("package-absent"); the
589
+ * soul no longer shipped by the package → "missing" ("soul-absent").
587
590
  */
588
591
  export function soulDriftOf(instanceJson, discovery) {
589
592
  const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
590
593
  if (!soul || typeof soul.repoKey !== "string") return null;
591
594
  const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
592
595
  const commit = typeof soul.commit === "string" ? soul.commit : null;
596
+ if (plainObject(soul.package) && typeof soul.package.id === "string") {
597
+ // A package soul: "moved" means the package pin moved (another version/commit is locked now).
598
+ const pkg = { package: soul.package.id, version: soul.package.version ?? null };
599
+ const soulName = typeof soul.name === "string" ? soul.name : name;
600
+ const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
601
+ const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
602
+ const now = listed.find((s) => s.name === soulName) || null;
603
+ if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
604
+ return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
605
+ }
593
606
  const members = Array.isArray(discovery?.members) ? discovery.members : [];
594
607
  const member = members.find((m) => m && m.key === soul.repoKey) || null;
595
608
  const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
package/lib/packages.mjs CHANGED
@@ -28,7 +28,8 @@
28
28
  * version: "<version string, no leading v>",
29
29
  * commit: "<full 40-hex OID>",
30
30
  * integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
31
- * capabilities: ["<cap name>", …] // sorted
31
+ * capabilities: ["<cap name>", …], // sorted
32
+ * souls: [{ name, path, digest }] // package souls (0.28.0), sorted by name; omitted when none
32
33
  * }
33
34
  * }
34
35
  * }
@@ -57,6 +58,11 @@ export const DEFAULT_PACKAGE_PATH = "oats-package";
57
58
 
58
59
  const OID_RE = /^[0-9a-f]{40}$/;
59
60
  const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
61
+ /** A soul name (docs/soul.schema.json#/$defs/slug): a package soul's name is its directory's. */
62
+ const SOUL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
63
+ /** The one symlink a soul source may carry: its CLAUDE.md → AGENTS.md alias (at the soul's root).
64
+ * Sync and spawn fetch a package soul with this same predicate, so their digests agree. */
65
+ export const SOUL_ALIAS_SYMLINK = (p) => p === "CLAUDE.md";
60
66
  const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
61
67
 
62
68
  /** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
@@ -143,6 +149,14 @@ export function validateLock(lock, { file } = {}) {
143
149
  if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
144
150
  if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
145
151
  if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
152
+ if (entry.souls !== undefined) {
153
+ if (!Array.isArray(entry.souls)) throw bad("souls", "must be an array of { name, path, digest }");
154
+ for (const [i, soul] of entry.souls.entries()) {
155
+ if (!plainObject(soul) || typeof soul.name !== "string" || !SOUL_NAME_RE.test(soul.name)) throw bad(`souls/${i}/name`, "must be a soul name");
156
+ if (typeof soul.path !== "string" || !soul.path || soul.path.split("/").some((p) => p === ".." || p === "") || soul.path.startsWith("/")) throw bad(`souls/${i}/path`, "must be a relative path inside the package");
157
+ if (typeof soul.digest !== "string" || !DIGEST_RE.test(soul.digest)) throw bad(`souls/${i}/digest`, "must be sha256-<hex>");
158
+ }
159
+ }
146
160
  }
147
161
  return lock;
148
162
  }
@@ -168,6 +182,7 @@ export function canonicalLock(lock) {
168
182
  packages[id] = {
169
183
  source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
170
184
  capabilities: [...e.capabilities].sort(),
185
+ ...(Array.isArray(e.souls) && e.souls.length ? { souls: [...e.souls].sort((a, b) => byCodepoint(a.name, b.name)).map((x) => ({ name: x.name, path: x.path, digest: x.digest })) } : {}),
171
186
  };
172
187
  }
173
188
  return { lockfileVersion: LOCK_VERSION, packages };
@@ -319,7 +334,63 @@ export async function readPackageManifests(remote, remoteRef, commit, path, deta
319
334
  capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
320
335
  }
321
336
  capabilities.sort((a, b) => byCodepoint(a.name, b.name));
322
- return { manifest, capabilities };
337
+ // Package souls (0.28.0): ordinary soul directories, versioned and locked with the package. A soul's
338
+ // name is its directory's (as for a member's souls/<name>); soul.yaml is validated where it is
339
+ // discovered, AGENTS.md is required where it is locked (packageSoulDigests).
340
+ const souls = [];
341
+ if (manifest.souls !== undefined) {
342
+ if (!Array.isArray(manifest.souls)) throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath}: "souls" must be a list of soul directories`, { ...details, path: manifestPath });
343
+ const seenSouls = new Map();
344
+ for (const rel of manifest.souls) {
345
+ assertRelPath(`${manifestPath} souls[]`, rel, { ...details, path: manifestPath });
346
+ const soulPath = posix.normalize(rel).replace(/\/+$/, "");
347
+ const name = posix.basename(soulPath);
348
+ if (!SOUL_NAME_RE.test(name)) throw oatsError("E_PACKAGE_MANIFEST", `${manifestPath} souls[]: ${JSON.stringify(rel)} — a soul's directory is its name, which must match ${SOUL_NAME_RE}`, { ...details, path: manifestPath, soul: rel });
349
+ if (seenSouls.has(name)) throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares soul ${JSON.stringify(name)} twice (${seenSouls.get(name)} and ${soulPath})`, { ...details, path: manifestPath, duplicate: name, other: seenSouls.get(name) });
350
+ seenSouls.set(name, soulPath);
351
+ souls.push({ name, path: soulPath, dir: pjoin(path, soulPath) });
352
+ }
353
+ souls.sort((a, b) => byCodepoint(a.name, b.name));
354
+ }
355
+ return { manifest, capabilities, souls };
356
+ }
357
+
358
+ /** Fetch each package soul at `commit` and return its lock record `{ name, path, digest }` — the digest
359
+ * is what `fetchRemoteTree` reports with SOUL_ALIAS_SYMLINK, the same fetch a spawn makes and verifies.
360
+ * A soul without soul.yaml or AGENTS.md is E_PACKAGE_MANIFEST. */
361
+ export async function packageSoulDigests(remote, remoteRef, commit, souls, details = {}) {
362
+ if (!souls.length) return [];
363
+ if (typeof remote.fetchRemoteTree !== "function") throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package soul's digest", { path: "/remote" });
364
+ const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-soul-"));
365
+ try {
366
+ const out = [];
367
+ for (const soul of souls) {
368
+ const dest = join(scratch, soul.name);
369
+ let digest;
370
+ try { ({ digest } = await remote.fetchRemoteTree(remoteRef, commit, soul.dir, dest, { allowSymlinks: SOUL_ALIAS_SYMLINK })); }
371
+ catch (e) {
372
+ if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} is missing at ${String(commit).slice(0, 12)}`, { ...details, soul: soul.name, path: soul.dir });
373
+ throw e;
374
+ }
375
+ // A fake remote reports a digest without copying: only a real copy can be checked for its files.
376
+ if (existsSync(dest)) {
377
+ for (const file of ["soul.yaml", "AGENTS.md"]) {
378
+ if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
379
+ }
380
+ }
381
+ out.push({ name: soul.name, path: soul.path, digest: assertDigest(`package soul ${soul.path} digest`, digest, details) });
382
+ }
383
+ return out;
384
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
385
+ }
386
+
387
+ /** The repo ref a lock entry is read from without a catalog: its recorded url, else the key of a
388
+ * `git:<key>@<ref>` source. → string | null (a catalog entry written without a url). */
389
+ export function lockedPackageRef(entry) {
390
+ if (typeof entry?.url === "string" && entry.url) return entry.url;
391
+ const m = /^git:(.+)@([^@]+)$/.exec(String(entry?.source ?? ""));
392
+ if (!m) return null;
393
+ return m[1].startsWith("local/") ? m[1].slice("local/".length) : `git:${m[1]}`;
323
394
  }
324
395
 
325
396
  /** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
@@ -419,23 +490,36 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
419
490
  // The lock's capability list must still be what the package declares at that commit —
420
491
  // spawn refuses a mismatch (E_PACKAGE_INTEGRITY why:capabilities), so sync must too, or
421
492
  // a bad list would pass every sync and fail every spawn.
422
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
493
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
423
494
  const listed = capabilities.map((c) => c.name).sort(), locked = [...old.capabilities].sort();
424
495
  if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
425
496
  throw oatsError("E_PACKAGE_INTEGRITY",
426
497
  `packages.${id} ${version} @ ${obs.commit}: the lock lists capabilities [${locked.join(", ")}] but the package declares [${listed.join(", ")}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
427
498
  { ...details, version, why: "capabilities", listed, locked });
428
499
  }
429
- const { approved: _dropped, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
430
- nextPackages[id] = kept;
500
+ // The lock's souls must be what the package ships at that commit: the same names, paths and
501
+ // digests. A lock written before 0.28.0 carries no `souls` — it is filled in, not refused.
502
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
503
+ if (old.souls !== undefined) {
504
+ const show = (list) => list.map((x) => `${x.name}@${x.path}:${x.digest.slice(7, 19)}`).sort().join(", ");
505
+ if (show(old.souls) !== show(souls)) {
506
+ throw oatsError("E_PACKAGE_INTEGRITY",
507
+ `packages.${id} ${version} @ ${obs.commit}: the lock's souls [${show(old.souls)}] do not match what the package ships [${show(souls)}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
508
+ { ...details, version, why: "souls", listed: souls, locked: old.souls });
509
+ }
510
+ }
511
+ const { approved: _dropped, souls: _souls, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
512
+ nextPackages[id] = { ...kept, ...(souls.length ? { souls } : {}) };
431
513
  continue;
432
514
  }
433
515
 
434
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
516
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
435
517
  const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
518
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
436
519
  nextPackages[id] = {
437
520
  source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
438
521
  capabilities: capabilities.map((c) => c.name).sort(),
522
+ ...(souls.length ? { souls } : {}),
439
523
  };
440
524
  changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit });
441
525
  }
package/lib/resolve.mjs CHANGED
@@ -472,6 +472,15 @@ function lookupPackage(lock, name, via, soul, declared) {
472
472
  return providing;
473
473
  }
474
474
 
475
+ /** `from: here` in a package soul: the capability must be one its OWN locked package provides. */
476
+ function ownPackageEntry(lock, name, via, soul, id) {
477
+ const where = { capability: name, from: "here", soul: soul.name, via, package: id };
478
+ const entry = isObject(lock?.packages) ? lock.packages[id] : null;
479
+ if (!isObject(entry)) throw fail("E_PACKAGE_MISSING", `${name}: from: here in package soul ${id}/${soul.name} needs ${id} in the lock — run \`oats sync\``, { ...where, reason: "no-lock" });
480
+ if (!entry.capabilities.includes(name)) throw fail("E_CAPABILITY_MISSING", `${name}: from: here in package soul ${id}/${soul.name}, but package ${id} v${entry.version} provides [${entry.capabilities.join(", ")}]`, { ...where, version: entry.version, listed: [...entry.capabilities] });
481
+ return { id, entry };
482
+ }
483
+
475
484
  /** 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. */
476
485
  export function packageRef(id, entry, catalog, remote) {
477
486
  const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
@@ -582,8 +591,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
582
591
  const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
583
592
  for (const { name, from, via } of declared) {
584
593
  let module;
585
- if (from === "package") {
586
- const { id, entry } = lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
594
+ const ownPackage = from === "here" && typeof soulEntry.package === "string";
595
+ if (from === "package" || ownPackage) {
596
+ // `from: here` in a package soul is its own package, at the locked commit (§2.2).
597
+ const { id, entry } = ownPackage
598
+ ? ownPackageEntry(lock, name, via, soul, soulEntry.package)
599
+ : lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
587
600
  const ref = packageRef(id, entry, catalog, remote);
588
601
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
589
602
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
@@ -783,6 +796,11 @@ function assertSoulDiscovered(discovery, soulEntry) {
783
796
  const { name, repoKey } = soulEntry;
784
797
  const where = { soul: name, repoKey };
785
798
  const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
799
+ if (typeof soulEntry.package === "string") {
800
+ // A package soul: the discovery listed it from the lock, at this package's locked commit.
801
+ if ((discovery?.packageSouls || []).some((s) => sameSoul(s) && s.package === soulEntry.package)) return;
802
+ throw fail("E_PACKAGE_MISSING", `soul ${soulEntry.package}/${name}: the discovery does not list it at ${short(soulEntry.commit)} — the soul entry is stale; run \`oats sync\` and discover again`, { ...where, package: soulEntry.package, reason: "stale" });
803
+ }
786
804
  if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
787
805
  const row = memberRow(discovery, repoKey);
788
806
  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" });