@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,387 @@
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, realpathSync } from "node:fs";
20
+ import { join, resolve as resolvePath, dirname, basename, relative, isAbsolute, sep } 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
+ /** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
102
+ * Each entry is IMMUTABLE once written (fetched to staging, renamed in) and is never
103
+ * removed by the kernel — a running instance's `<home>/soul` links straight at its
104
+ * own commit's directory (realpath), so nothing can change under it. */
105
+ export const SOULS_DIR = "souls";
106
+ export const SOUL_SOURCE_STAMP = ".oats-soul-source.json";
107
+ const commit12 = (c) => String(c || "").slice(0, 12) || "unknown";
108
+ /** The kernel-owned "current" pointer `<agentDir>/soul` is a symlink whose target
109
+ * sits inside `<agentDir>/souls/`. Returns that target's realpath, or null when the
110
+ * path is absent, a real directory (0.25.0 layout) or a symlink elsewhere. */
111
+ export function soulPointerTarget(soulDir) {
112
+ let st; try { st = lstatSync(soulDir); } catch { return null; }
113
+ if (!st.isSymbolicLink()) return null;
114
+ let real; try { real = realpathSync(soulDir); } catch { return null; }
115
+ let soulsReal; try { soulsReal = realpathSync(join(dirname(soulDir), SOULS_DIR)); } catch { return null; }
116
+ const rel = relative(soulsReal, real);
117
+ if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
118
+ return real;
119
+ }
120
+ /** Atomically point `<agentDir>/soul` at `target`: symlink to a temp name, rename over.
121
+ * The previous target directory is untouched (an instance may link it). */
122
+ function swapSoulPointer(soulDir, target) {
123
+ const tmp = `${soulDir}.pointer-${process.pid}-${randomBytes(4).toString("hex")}`;
124
+ try {
125
+ symlinkSync(target, tmp);
126
+ renameSync(tmp, soulDir); // rename over an existing symlink replaces it; over a directory it fails (caller migrates first)
127
+ } catch (x) { try { rmSync(tmp, { force: true }); } catch { /* nothing */ } throw x; }
128
+ }
129
+
130
+ /** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into the
131
+ * per-commit cache `<agentsRoot>/<name>/souls/<commit12>/` and point the classic
132
+ * `<agentsRoot>/<name>/soul` at it (a SYMLINK, swapped atomically), so the classic
133
+ * spawn skeleton, findAgent, doctor … keep reading "current" from the usual place
134
+ * while every spawned home links its OWN commit's directory (decision 7: an
135
+ * instance never changes under itself — a preview or a later spawn may fetch a new
136
+ * commit and move the pointer, but no directory an instance links is ever touched).
137
+ *
138
+ * Idempotent per (repo, commit): an existing complete entry is reused (never
139
+ * refetched, never rewritten); an entry that lost its soul.yaml is moved aside and
140
+ * refetched. `.oats-soul-source.json` stamps what the pointer currently shows.
141
+ * Migration: a 0.25.0 `soul/` that is a REAL directory is moved to
142
+ * `souls/<commit from the stamp | unknown>/` before the pointer replaces it.
143
+ * Returns the PER-COMMIT directory (what a home should link). */
144
+ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
145
+ const e = prepared.soulEntry;
146
+ const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
147
+ const soulsDir = join(agentDir, SOULS_DIR);
148
+ const stamp = join(agentDir, SOUL_SOURCE_STAMP);
149
+ const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
150
+ const commitDir = join(soulsDir, commit12(e.commit));
151
+ mkdirSync(soulsDir, { recursive: true });
152
+
153
+ // --- migration: a 0.25.0 real `soul/` directory becomes souls/<commit|unknown>/ ---
154
+ let st; try { st = lstatSync(soulDir); } catch { st = null; }
155
+ if (st && !st.isSymbolicLink() && st.isDirectory()) {
156
+ const cur = readStamp();
157
+ const legacyCommit = cur && cur.repoKey === e.repoKey && typeof cur.commit === "string" && cur.commit ? commit12(cur.commit) : "unknown";
158
+ let dest = join(soulsDir, legacyCommit);
159
+ if (existsSync(dest)) dest = join(soulsDir, `${legacyCommit}.migrated-${process.pid}-${randomBytes(4).toString("hex")}`);
160
+ renameSync(soulDir, dest);
161
+ swapSoulPointer(soulDir, dest);
162
+ } else if (st && !st.isSymbolicLink()) {
163
+ // a regular file where the pointer belongs: not ours to keep
164
+ rmSync(soulDir, { force: true });
165
+ }
166
+
167
+ // --- the per-commit entry: reuse when complete, else fetch (staging → rename in) ---
168
+ let complete = existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"));
169
+ if (existsSync(commitDir) && !complete) {
170
+ // A damaged entry (someone removed soul.yaml): move it aside — an instance may
171
+ // still link it, so it is never deleted — and fetch a fresh copy under the canonical name.
172
+ renameSync(commitDir, `${commitDir}.damaged-${process.pid}-${randomBytes(4).toString("hex")}`);
173
+ }
174
+ if (!complete) {
175
+ const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
176
+ const staging = join(soulsDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
177
+ try {
178
+ // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
179
+ await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
180
+ 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 });
181
+ if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
182
+ try { renameSync(staging, commitDir); }
183
+ catch (x) {
184
+ // Lost a race with a concurrent fetch of the same commit: theirs is the same bytes.
185
+ if (!(existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md")))) throw x;
186
+ rmSync(staging, { recursive: true, force: true });
187
+ }
188
+ } catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
189
+ }
190
+
191
+ // --- the "current" pointer + its stamp ---
192
+ const target = realpathSync(commitDir);
193
+ if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
194
+ const cur = readStamp();
195
+ if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
196
+ writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
197
+ }
198
+ return target;
199
+ }
200
+
201
+ /** The async half of a spawn: everything that touches the network. Returns a
202
+ * PREPARED object that `spawnInstance` consumes synchronously. */
203
+ export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
204
+ const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
205
+ const local = found.local;
206
+ const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
207
+ const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
208
+ const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
209
+ const soulEntry = findSoulEntry(discovery, soulName);
210
+ const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
211
+ return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
212
+ }
213
+
214
+ /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
215
+ * operator's access context cannot see this host" — the ONLY reasons that turn a
216
+ * workspace spawn into the standalone view. `network` / `timeout` are transient: the
217
+ * workspace exists and is ours; a spawn must not quietly degrade to a standalone one. */
218
+ const ACCESS_REASONS = new Set(["auth", "not-found"]);
219
+ const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
220
+
221
+ /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
222
+ * repo must declare that workspace (oats-membership.yaml). A lone repo with no
223
+ * backlink is not a member of anything and never becomes a capability source. */
224
+ function requireMembership(repo, ref) {
225
+ if (repo.membership) return;
226
+ 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" });
227
+ }
228
+
229
+ /**
230
+ * Discover the workspace named by oats-local.yaml — or, when that ref is a REPO
231
+ * whose workspace cannot be read (decision 10, the standalone case: a public
232
+ * member of a privately hosted workspace), fall back to the repo's own souls and
233
+ * capabilities. `standalone: <repo ref>` in oats-local.yaml asks for the standalone
234
+ * view explicitly (no workspace lookup at all) — the repo must still be a member
235
+ * (carry oats-membership.yaml), exactly as on the fallback path.
236
+ *
237
+ * A standalone result carries `standalone: true` and `standaloneReason`:
238
+ * "explicit" — oats-local.yaml said `standalone:`;
239
+ * "unreadable-host" — `workspace:` named a member whose declared host is not
240
+ * readable in this access context (auth / not-found).
241
+ * A transient failure reading the host (network, timeout) is rethrown as-is.
242
+ */
243
+ export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
244
+ const ro = { remoteOptions, remote };
245
+ if (typeof local.standalone === "string" && local.standalone) {
246
+ const repo = await discoverRepo(local.standalone, ro);
247
+ requireMembership(repo, local.standalone);
248
+ return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
249
+ }
250
+ try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
251
+ catch (e) {
252
+ if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
253
+ // The ref is a repository without oats-workspace.yaml: is it a member whose
254
+ // workspace we cannot read? Then the standalone view is what the operator gets.
255
+ const repo = await discoverRepo(local.workspace, ro);
256
+ if (!repo.membership) throw e;
257
+ try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
258
+ catch (inner) {
259
+ // Only an ACCESS failure on the host means "standalone"; anything else
260
+ // (network, timeout, a broken workspace file) is the caller's to see.
261
+ if (!isAccessFailure(inner)) throw inner;
262
+ 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 } };
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Materialize a prepared resolution into `home`. Called by spawnInstance after
268
+ * the home directory exists and before the harness is launched. */
269
+ export async function materializePrepared(prepared, home) {
270
+ return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir });
271
+ }
272
+
273
+ /** Turn a Resolution's modules (already materialized under `home`) into the
274
+ * capability rows the kernel's hooks/environment/requirements code consumes.
275
+ * Paths point INTO the instance's own copy — never at a repo or a package dir. */
276
+ export function toCapabilityRows(resolution, home) {
277
+ const rows = [];
278
+ for (const m of resolution.modules) {
279
+ const dir = join(home, MODULES_DIR, m.name);
280
+ const manifest = m.manifest;
281
+ const skills = [];
282
+ const skillsRoot = join(home, SKILLS_DIR, m.name);
283
+ if (existsSync(skillsRoot)) for (const e of readdirSync(skillsRoot, { withFileTypes: true })) if (e.isDirectory()) skills.push(join(skillsRoot, e.name));
284
+ const inject = manifest.inject ? join(dir, manifest.inject) : undefined;
285
+ rows.push({
286
+ id: m.name, capability: m.name, manifest, layer: manifest.layer ?? undefined, command: manifest.command,
287
+ level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
288
+ provenance: [m.from.kind === "package" ? `package ${m.from.package} v${m.from.version}` : `member ${m.from.repoKey} @ ${String(m.from.commit).slice(0, 12)}`],
289
+ settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
290
+ skills, inject: inject && existsSync(inject) ? inject : undefined,
291
+ skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
292
+ // Member capabilities are trusted by membership (decision 2); package
293
+ // capabilities were approved per version at sync (E_PACKAGE_UNAPPROVED
294
+ // otherwise, so reaching here means approved).
295
+ hooks: hookCommandsOf(manifest, dir), requiredHooks: requiredHooksOf(manifest),
296
+ environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])],
297
+ missingRequires: [], compatibility: { ok: true }, trust: { trusted: true, reason: m.from.kind === "package" ? "approved package version" : "workspace member" },
298
+ executable: !!manifest.commands && Object.keys(manifest.commands).length > 0,
299
+ retirement: manifest.retirement,
300
+ dir, from: m.from, _scope: 0,
301
+ });
302
+ }
303
+ return rows;
304
+ }
305
+
306
+ function hookCommandsOf(manifest, dir) {
307
+ const out = {};
308
+ const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
309
+ for (const [event, spec] of Object.entries(hooks)) {
310
+ const cmd = typeof spec === "string" ? spec : spec?.command;
311
+ if (typeof cmd === "string" && cmd) out[event] = { command: cmd, cwd: dir, required: spec?.required === true };
312
+ }
313
+ return out;
314
+ }
315
+ function requiredHooksOf(manifest) {
316
+ const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
317
+ return Object.entries(hooks).filter(([, s]) => s && typeof s === "object" && s.required === true).map(([e]) => e);
318
+ }
319
+
320
+ /** What a preview shows about modules: from/commit/digest per module and whether
321
+ * it changed since the newest existing instance of the same soul in `agentsRoot`. */
322
+ export function modulesPreview(resolution, agentsRoot, soulName) {
323
+ let previous = null;
324
+ const instancesDir = join(agentsRoot, soulName, "instances");
325
+ if (existsSync(instancesDir)) {
326
+ let newest = null;
327
+ for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
328
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
329
+ try {
330
+ const meta = JSON.parse(readFileSync(join(instancesDir, e.name, "instance.json"), "utf8"));
331
+ if (meta?.modules && (!newest || String(meta.createdAt) > String(newest.createdAt))) newest = { name: e.name, ...meta };
332
+ } catch { /* not an instance */ }
333
+ }
334
+ previous = newest;
335
+ }
336
+ return resolution.modules.map((m) => {
337
+ const prev = previous?.modules?.[m.name];
338
+ const changedSince = !previous ? null : !prev ? { instance: previous.name, was: null } : (prev.commit !== m.from.commit ? { instance: previous.name, was: prev.commit } : false);
339
+ return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, changedSince };
340
+ });
341
+ }
342
+
343
+ /** The relative pi/claude/codex skill directory inside a home — exported so the
344
+ * launch recipe and tests agree on it. */
345
+ export const INSTANCE_SKILLS_DIR = SKILLS_DIR;
346
+ export const INSTANCE_MODULES_DIR = MODULES_DIR;
347
+
348
+
349
+ /**
350
+ * Workspace model: a capability-defined agent (a package capability's `agents:`
351
+ * soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
352
+ * not from any instance: every approved package's capability manifests are read
353
+ * over the remote; the first `agents/<name>` match has its capability tree fetched
354
+ * into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
355
+ * (a per-commit cache shared by every such spawn) so the classic capability-agent
356
+ * machinery can read it exactly as it reads a materialized instance module.
357
+ * → { capability, dir, commit, package, version, manifest, rel } | undefined.
358
+ * Unapproved packages are skipped (they could not have materialized anywhere).
359
+ */
360
+ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null } = {}) {
361
+ if (typeof name !== "string" || !name) return undefined;
362
+ const found = loadLocal(contextDir);
363
+ const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
364
+ if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
365
+ const lock = readLock(deployment);
366
+ const remote = defaultRemote;
367
+ for (const [id, entry] of Object.entries(lock.packages || {})) {
368
+ if (!entry || typeof entry !== "object" || !entry.approved || typeof entry.approved.executables !== "string") continue;
369
+ let ref;
370
+ try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
371
+ let read;
372
+ try { read = await readPackageManifests(remote, ref, entry.commit, entry.path, { package: id }); } catch { continue; }
373
+ for (const cap of read.capabilities) {
374
+ const agents = Array.isArray(cap.manifest.agents) ? cap.manifest.agents : [];
375
+ const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
376
+ if (!rel) continue;
377
+ const store = join(deployment, MODULES_DIR);
378
+ const dir = join(store, `${cap.name}@${String(entry.commit).slice(0, 12)}`);
379
+ if (!existsSync(join(dir, "oats.json"))) {
380
+ mkdirSync(store, { recursive: true });
381
+ await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
382
+ }
383
+ return { capability: cap.name, dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
384
+ }
385
+ }
386
+ return undefined;
387
+ }