@awebai/oats 0.24.12 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
@@ -0,0 +1,312 @@
1
+ /** Instance resolution — the bridge from the workspace model (remote → workspace →
2
+ * resolve → materialize) into a spawn.
3
+ *
4
+ * A spawn is: read this machine's `oats-local.yaml` → discover the workspace over
5
+ * its Git remotes in the operator's access context → find the soul among the
6
+ * confirmed members (or external souls) → resolve every capability by `from:`
7
+ * (member = latest state, package = the locked, approved version) → materialize
8
+ * each capability WHOLE into the new home (`.oats/modules/`, `.agents/skills/`) →
9
+ * compose AGENTS.md → launch the harness normally.
10
+ *
11
+ * This module owns the async half (discover + resolve) and the materialize call;
12
+ * `core.mjs#spawnInstance` stays synchronous and consumes a PREPARED resolution
13
+ * (`o.prepared`) produced here. It also turns a Resolution into the capability
14
+ * row shape the rest of the kernel already understands (`toCapabilityRows`), so
15
+ * hooks, environment, requirements and retirement keep working unchanged.
16
+ *
17
+ * Nothing here reads `oats-config.yaml`, an installed-capability directory or a
18
+ * per-soul `source:` — those do not exist in this model. */
19
+ import { existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
20
+ import { join, resolve as resolvePath, dirname, basename } from "node:path";
21
+ import { oatsError } from "./errors.mjs";
22
+ import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./workspace.mjs";
23
+ import { resolveSoul, packageRef } from "./resolve.mjs";
24
+ import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
25
+ import { fetchRemoteTree } from "./remote.mjs";
26
+ import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
27
+ import { randomBytes } from "node:crypto";
28
+ import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
29
+ import * as defaultRemote from "./remote.mjs";
30
+ import { parseRepoRef } from "./remote.mjs";
31
+
32
+ function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
33
+
34
+ /** Keys that would poison a payload's prototype (as own keys) or, as a CAPABILITY
35
+ * name, address an inherited member of a plain `{}` (`constructor`, `toString`, …).
36
+ * Same set as lib/resolve.mjs POISON_KEYS; refused with E_WORKSPACE_SCHEMA reason
37
+ * "poison-key" so a `--provider constructor x=1` never reaches Object.prototype. */
38
+ const POISON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
39
+ /** `byTeam` is reserved: legal ONLY at the top level of workspace.messaging (decision 23);
40
+ * a spawn payload may never smuggle it in. */
41
+ const RESERVED_KEY = "byTeam";
42
+
43
+ /** Parse repeated `--provider <cap> k=v` occurrences into { <cap>: { k: v } }.
44
+ * Values are strings; `k=v=w` keeps everything after the first `=`. Poison keys
45
+ * (capability OR any path segment) are E_WORKSPACE_SCHEMA reason "poison-key";
46
+ * `byTeam` (any path segment) is E_WORKSPACE_SCHEMA reason "reserved-key". Both are
47
+ * refused again in resolve (defense in depth). The accumulators are built with
48
+ * Object.create(null) and only OWN keys are ever reused, so a name that happens to
49
+ * exist on Object.prototype (`toString`, `hasOwnProperty`) never writes through to it. */
50
+ export function parseProviderFlags(pairs) {
51
+ const out = Object.create(null);
52
+ for (const [cap, kv] of pairs) {
53
+ if (typeof cap !== "string" || !/^[a-z][a-z0-9.-]{0,63}$/i.test(cap)) throw err("E_BAD_ARGS", `--provider needs <capability> <key>=<value> (got capability ${JSON.stringify(cap)})`);
54
+ if (POISON_KEYS.has(cap)) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: capability name ${JSON.stringify(cap)} is refused (it would poison the payload's prototype)`, { path: `/${cap}`, key: cap, reason: "poison-key" });
55
+ if (typeof kv !== "string" || !kv.includes("=")) throw err("E_BAD_ARGS", `--provider ${cap}: expected <key>=<value>, got ${JSON.stringify(kv)}`);
56
+ const i = kv.indexOf("=");
57
+ const key = kv.slice(0, i), value = kv.slice(i + 1);
58
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(key)) throw err("E_BAD_ARGS", `--provider ${cap}: invalid key ${JSON.stringify(key)}`);
59
+ // dotted keys nest: identity.source=retained:x → { identity: { source: "retained:x" } }
60
+ const parts = key.split(".");
61
+ for (const p of parts) {
62
+ if (POISON_KEYS.has(p)) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (segment ${JSON.stringify(p)} would poison the payload's prototype)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "poison-key" });
63
+ if (p === RESERVED_KEY) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (${JSON.stringify(RESERVED_KEY)} is reserved for the top level of workspace.messaging)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "reserved-key" });
64
+ }
65
+ if (!Object.hasOwn(out, cap)) out[cap] = Object.create(null);
66
+ let cur = out[cap];
67
+ for (const p of parts.slice(0, -1)) {
68
+ if (!Object.hasOwn(cur, p) || cur[p] === null || typeof cur[p] !== "object") cur[p] = Object.create(null);
69
+ cur = cur[p];
70
+ }
71
+ cur[parts.at(-1)] = value;
72
+ }
73
+ // Hand back ordinary objects (JSON-clean, Object.prototype) now that every key is vetted.
74
+ return JSON.parse(JSON.stringify(out));
75
+ }
76
+
77
+ /** Find the soul named `name` in a discovery: confirmed members first (by
78
+ * `repo/name` or bare name when unique), then external souls. */
79
+ export function findSoulEntry(discovery, name) {
80
+ const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
81
+ const hits = [];
82
+ // A standalone view's one row is the repo's own (unconfirmed by definition — the
83
+ // workspace could not be read); resolveSoul admits exactly that case.
84
+ const standaloneOwn = discovery.standalone === true ? discovery.key : null;
85
+ for (const m of discovery.members || []) {
86
+ if (!m.confirmed && m.key !== standaloneOwn) continue;
87
+ for (const s of m.souls || []) {
88
+ if (s.name !== soulPart) continue;
89
+ if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
90
+ hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
91
+ }
92
+ }
93
+ for (const x of discovery.external || []) {
94
+ 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 });
95
+ }
96
+ 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) });
97
+ 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) });
98
+ return hits[0];
99
+ }
100
+
101
+ /** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into
102
+ * `<agentsRoot>/<name>/soul/` so the classic spawn skeleton (which reads the
103
+ * soul from a directory) can proceed. Idempotent per (repo, commit): a soul dir
104
+ * already at that commit is reused; a different commit replaces it atomically
105
+ * (instances keep their own copies, so this is safe). Returns the soul dir. */
106
+ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
107
+ const e = prepared.soulEntry;
108
+ const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
109
+ const stamp = join(agentDir, ".oats-soul-source.json");
110
+ try { const cur = JSON.parse(readFileSync(stamp, "utf8")); if (cur.repoKey === e.repoKey && cur.commit === e.commit && existsSync(join(soulDir, "soul.yaml"))) return soulDir; } catch { /* absent or stale */ }
111
+ const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
112
+ const staging = join(agentDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
113
+ mkdirSync(agentDir, { recursive: true });
114
+ try {
115
+ // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
116
+ await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
117
+ if (!existsSync(join(staging, "soul.yaml")) || !existsSync(join(staging, "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 });
118
+ if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
119
+ if (existsSync(soulDir)) { const old = `${soulDir}.previous-${Date.now()}`; renameSync(soulDir, old); try { rmSync(old, { recursive: true, force: true }); } catch { /* leave it */ } }
120
+ renameSync(staging, soulDir);
121
+ writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
122
+ return soulDir;
123
+ } catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
124
+ }
125
+
126
+ /** The async half of a spawn: everything that touches the network. Returns a
127
+ * PREPARED object that `spawnInstance` consumes synchronously. */
128
+ export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
129
+ const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
130
+ const local = found.local;
131
+ const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
132
+ const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
133
+ const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
134
+ const soulEntry = findSoulEntry(discovery, soulName);
135
+ const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
136
+ return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
137
+ }
138
+
139
+ /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
140
+ * operator's access context cannot see this host" — the ONLY reasons that turn a
141
+ * workspace spawn into the standalone view. `network` / `timeout` are transient: the
142
+ * workspace exists and is ours; a spawn must not quietly degrade to a standalone one. */
143
+ const ACCESS_REASONS = new Set(["auth", "not-found"]);
144
+ const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
145
+
146
+ /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
147
+ * repo must declare that workspace (oats-membership.yaml). A lone repo with no
148
+ * backlink is not a member of anything and never becomes a capability source. */
149
+ function requireMembership(repo, ref) {
150
+ if (repo.membership) return;
151
+ throw err("E_MEMBERSHIP_UNCONFIRMED", `a standalone view is a MEMBER whose workspace cannot be read; ${repo.key} declares no workspace (no oats-membership.yaml at ${String(repo.commit).slice(0, 12)})`, { key: repo.key, commit: repo.commit, ref, reason: "no-backlink" });
152
+ }
153
+
154
+ /**
155
+ * Discover the workspace named by oats-local.yaml — or, when that ref is a REPO
156
+ * whose workspace cannot be read (decision 10, the standalone case: a public
157
+ * member of a privately hosted workspace), fall back to the repo's own souls and
158
+ * capabilities. `standalone: <repo ref>` in oats-local.yaml asks for the standalone
159
+ * view explicitly (no workspace lookup at all) — the repo must still be a member
160
+ * (carry oats-membership.yaml), exactly as on the fallback path.
161
+ *
162
+ * A standalone result carries `standalone: true` and `standaloneReason`:
163
+ * "explicit" — oats-local.yaml said `standalone:`;
164
+ * "unreadable-host" — `workspace:` named a member whose declared host is not
165
+ * readable in this access context (auth / not-found).
166
+ * A transient failure reading the host (network, timeout) is rethrown as-is.
167
+ */
168
+ export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
169
+ const ro = { remoteOptions, remote };
170
+ if (typeof local.standalone === "string" && local.standalone) {
171
+ const repo = await discoverRepo(local.standalone, ro);
172
+ requireMembership(repo, local.standalone);
173
+ return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
174
+ }
175
+ try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
176
+ catch (e) {
177
+ if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
178
+ // The ref is a repository without oats-workspace.yaml: is it a member whose
179
+ // workspace we cannot read? Then the standalone view is what the operator gets.
180
+ const repo = await discoverRepo(local.workspace, ro);
181
+ if (!repo.membership) throw e;
182
+ try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
183
+ catch (inner) {
184
+ // Only an ACCESS failure on the host means "standalone"; anything else
185
+ // (network, timeout, a broken workspace file) is the caller's to see.
186
+ if (!isAccessFailure(inner)) throw inner;
187
+ return { ...standaloneRepo(local.workspace, repo.commit, repo, { remote }), standaloneReason: "unreadable-host", hostFailure: { code: inner.code, reason: inner.details?.reason ?? inner.provenance?.reason ?? null, url: inner.details?.url ?? inner.provenance?.url ?? null } };
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Materialize a prepared resolution into `home`. Called by spawnInstance after
193
+ * the home directory exists and before the harness is launched. */
194
+ export async function materializePrepared(prepared, home) {
195
+ return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir });
196
+ }
197
+
198
+ /** Turn a Resolution's modules (already materialized under `home`) into the
199
+ * capability rows the kernel's hooks/environment/requirements code consumes.
200
+ * Paths point INTO the instance's own copy — never at a repo or a package dir. */
201
+ export function toCapabilityRows(resolution, home) {
202
+ const rows = [];
203
+ for (const m of resolution.modules) {
204
+ const dir = join(home, MODULES_DIR, m.name);
205
+ const manifest = m.manifest;
206
+ const skills = [];
207
+ const skillsRoot = join(home, SKILLS_DIR, m.name);
208
+ if (existsSync(skillsRoot)) for (const e of readdirSync(skillsRoot, { withFileTypes: true })) if (e.isDirectory()) skills.push(join(skillsRoot, e.name));
209
+ const inject = manifest.inject ? join(dir, manifest.inject) : undefined;
210
+ rows.push({
211
+ id: m.name, capability: m.name, manifest, layer: manifest.layer ?? undefined, command: manifest.command,
212
+ level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
213
+ provenance: [m.from.kind === "package" ? `package ${m.from.package} v${m.from.version}` : `member ${m.from.repoKey} @ ${String(m.from.commit).slice(0, 12)}`],
214
+ settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
215
+ skills, inject: inject && existsSync(inject) ? inject : undefined,
216
+ skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
217
+ // Member capabilities are trusted by membership (decision 2); package
218
+ // capabilities were approved per version at sync (E_PACKAGE_UNAPPROVED
219
+ // otherwise, so reaching here means approved).
220
+ hooks: hookCommandsOf(manifest, dir), requiredHooks: requiredHooksOf(manifest),
221
+ environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])],
222
+ missingRequires: [], compatibility: { ok: true }, trust: { trusted: true, reason: m.from.kind === "package" ? "approved package version" : "workspace member" },
223
+ executable: !!manifest.commands && Object.keys(manifest.commands).length > 0,
224
+ retirement: manifest.retirement,
225
+ dir, from: m.from, _scope: 0,
226
+ });
227
+ }
228
+ return rows;
229
+ }
230
+
231
+ function hookCommandsOf(manifest, dir) {
232
+ const out = {};
233
+ const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
234
+ for (const [event, spec] of Object.entries(hooks)) {
235
+ const cmd = typeof spec === "string" ? spec : spec?.command;
236
+ if (typeof cmd === "string" && cmd) out[event] = { command: cmd, cwd: dir, required: spec?.required === true };
237
+ }
238
+ return out;
239
+ }
240
+ function requiredHooksOf(manifest) {
241
+ const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
242
+ return Object.entries(hooks).filter(([, s]) => s && typeof s === "object" && s.required === true).map(([e]) => e);
243
+ }
244
+
245
+ /** What a preview shows about modules: from/commit/digest per module and whether
246
+ * it changed since the newest existing instance of the same soul in `agentsRoot`. */
247
+ export function modulesPreview(resolution, agentsRoot, soulName) {
248
+ let previous = null;
249
+ const instancesDir = join(agentsRoot, soulName, "instances");
250
+ if (existsSync(instancesDir)) {
251
+ let newest = null;
252
+ for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
253
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
254
+ try {
255
+ const meta = JSON.parse(readFileSync(join(instancesDir, e.name, "instance.json"), "utf8"));
256
+ if (meta?.modules && (!newest || String(meta.createdAt) > String(newest.createdAt))) newest = { name: e.name, ...meta };
257
+ } catch { /* not an instance */ }
258
+ }
259
+ previous = newest;
260
+ }
261
+ return resolution.modules.map((m) => {
262
+ const prev = previous?.modules?.[m.name];
263
+ const changedSince = !previous ? null : !prev ? { instance: previous.name, was: null } : (prev.commit !== m.from.commit ? { instance: previous.name, was: prev.commit } : false);
264
+ return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, changedSince };
265
+ });
266
+ }
267
+
268
+ /** The relative pi/claude/codex skill directory inside a home — exported so the
269
+ * launch recipe and tests agree on it. */
270
+ export const INSTANCE_SKILLS_DIR = SKILLS_DIR;
271
+ export const INSTANCE_MODULES_DIR = MODULES_DIR;
272
+
273
+
274
+ /**
275
+ * Workspace model: a capability-defined agent (a package capability's `agents:`
276
+ * soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
277
+ * not from any instance: every approved package's capability manifests are read
278
+ * over the remote; the first `agents/<name>` match has its capability tree fetched
279
+ * into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
280
+ * (a per-commit cache shared by every such spawn) so the classic capability-agent
281
+ * machinery can read it exactly as it reads a materialized instance module.
282
+ * → { capability, dir, commit, package, version, manifest, rel } | undefined.
283
+ * Unapproved packages are skipped (they could not have materialized anywhere).
284
+ */
285
+ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null } = {}) {
286
+ if (typeof name !== "string" || !name) return undefined;
287
+ const found = loadLocal(contextDir);
288
+ const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
289
+ if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
290
+ const lock = readLock(deployment);
291
+ const remote = defaultRemote;
292
+ for (const [id, entry] of Object.entries(lock.packages || {})) {
293
+ if (!entry || typeof entry !== "object" || !entry.approved || typeof entry.approved.executables !== "string") continue;
294
+ let ref;
295
+ try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
296
+ let read;
297
+ try { read = await readPackageManifests(remote, ref, entry.commit, entry.path, { package: id }); } catch { continue; }
298
+ for (const cap of read.capabilities) {
299
+ const agents = Array.isArray(cap.manifest.agents) ? cap.manifest.agents : [];
300
+ const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
301
+ if (!rel) continue;
302
+ const store = join(deployment, MODULES_DIR);
303
+ const dir = join(store, `${cap.name}@${String(entry.commit).slice(0, 12)}`);
304
+ if (!existsSync(join(dir, "oats.json"))) {
305
+ mkdirSync(store, { recursive: true });
306
+ await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
307
+ }
308
+ return { capability: cap.name, dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
309
+ }
310
+ }
311
+ return undefined;
312
+ }