@awebai/oats 0.25.0 → 0.25.2

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.
@@ -16,14 +16,15 @@
16
16
  *
17
17
  * Nothing here reads `oats-config.yaml`, an installed-capability directory or a
18
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";
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
21
  import { oatsError } from "./errors.mjs";
22
22
  import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./workspace.mjs";
23
23
  import { resolveSoul, packageRef } from "./resolve.mjs";
24
24
  import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
25
25
  import { fetchRemoteTree } from "./remote.mjs";
26
- import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
26
+ import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
27
+ import { spawnSync } from "node:child_process";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
29
30
  import * as defaultRemote from "./remote.mjs";
@@ -98,29 +99,233 @@ export function findSoulEntry(discovery, name) {
98
99
  return hits[0];
99
100
  }
100
101
 
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. */
102
+ /** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
103
+ * Each entry is IMMUTABLE once written (fetched to staging, renamed in) and is never
104
+ * removed by the kernel — a running instance's `<home>/soul` links straight at its
105
+ * own commit's directory (realpath), so nothing can change under it. */
106
+ export const SOULS_DIR = "souls";
107
+ export const SOUL_SOURCE_STAMP = ".oats-soul-source.json";
108
+ const commit12 = (c) => String(c || "").slice(0, 12) || "unknown";
109
+ /** The kernel-owned "current" pointer `<agentDir>/soul` is a symlink whose target
110
+ * sits inside `<agentDir>/souls/`. Returns that target's realpath, or null when the
111
+ * path is absent, a real directory (0.25.0 layout) or a symlink elsewhere. */
112
+ export function soulPointerTarget(soulDir) {
113
+ let st; try { st = lstatSync(soulDir); } catch { return null; }
114
+ if (!st.isSymbolicLink()) return null;
115
+ let real; try { real = realpathSync(soulDir); } catch { return null; }
116
+ let soulsReal; try { soulsReal = realpathSync(join(dirname(soulDir), SOULS_DIR)); } catch { return null; }
117
+ const rel = relative(soulsReal, real);
118
+ if (!rel || rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
119
+ return real;
120
+ }
121
+ /** Atomically point `<agentDir>/soul` at `target`: symlink to a temp name, rename over.
122
+ * The previous target directory is untouched (an instance may link it). */
123
+ function swapSoulPointer(soulDir, target) {
124
+ const tmp = `${soulDir}.pointer-${process.pid}-${randomBytes(4).toString("hex")}`;
125
+ try {
126
+ symlinkSync(target, tmp);
127
+ renameSync(tmp, soulDir); // rename over an existing symlink replaces it; over a directory it fails (caller migrates first)
128
+ } catch (x) { try { rmSync(tmp, { force: true }); } catch { /* nothing */ } throw x; }
129
+ }
130
+
131
+ /** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into the
132
+ * per-commit cache `<agentsRoot>/<name>/souls/<commit12>/` and point the classic
133
+ * `<agentsRoot>/<name>/soul` at it (a SYMLINK, swapped atomically), so the classic
134
+ * spawn skeleton, findAgent, doctor … keep reading "current" from the usual place
135
+ * while every spawned home links its OWN commit's directory (decision 7: an
136
+ * instance never changes under itself — a preview or a later spawn may fetch a new
137
+ * commit and move the pointer, but no directory an instance links is ever touched).
138
+ *
139
+ * Idempotent per (repo, commit): an existing complete entry is reused (never
140
+ * refetched, never rewritten); an entry that lost its soul.yaml is moved aside and
141
+ * refetched. `.oats-soul-source.json` stamps what the pointer currently shows.
142
+ * Migration: a 0.25.0 `soul/` that is a REAL directory is moved to
143
+ * `souls/<commit from the stamp | unknown>/` before the pointer replaces it.
144
+ * Returns the PER-COMMIT directory (what a home should link). */
106
145
  export async function ensureWorkspaceSoul(prepared, agentsRoot) {
107
146
  const e = prepared.soulEntry;
108
147
  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);
148
+ const soulsDir = join(agentDir, SOULS_DIR);
149
+ const stamp = join(agentDir, SOUL_SOURCE_STAMP);
150
+ const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
151
+ const commitDir = join(soulsDir, commit12(e.commit));
152
+ mkdirSync(soulsDir, { recursive: true });
153
+
154
+ // --- migration: a 0.25.0 real `soul/` directory becomes souls/<commit|unknown>/ ---
155
+ let st; try { st = lstatSync(soulDir); } catch { st = null; }
156
+ if (st && !st.isSymbolicLink() && st.isDirectory()) {
157
+ const cur = readStamp();
158
+ const legacyCommit = cur && cur.repoKey === e.repoKey && typeof cur.commit === "string" && cur.commit ? commit12(cur.commit) : "unknown";
159
+ let dest = join(soulsDir, legacyCommit);
160
+ if (existsSync(dest)) dest = join(soulsDir, `${legacyCommit}.migrated-${process.pid}-${randomBytes(4).toString("hex")}`);
161
+ renameSync(soulDir, dest);
162
+ swapSoulPointer(soulDir, dest);
163
+ } else if (st && !st.isSymbolicLink()) {
164
+ // a regular file where the pointer belongs: not ours to keep
165
+ rmSync(soulDir, { force: true });
166
+ }
167
+
168
+ // --- the per-commit entry: reuse when complete, else fetch (staging → rename in) ---
169
+ let complete = existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"));
170
+ if (existsSync(commitDir) && !complete) {
171
+ // A damaged entry (someone removed soul.yaml): move it aside — an instance may
172
+ // still link it, so it is never deleted — and fetch a fresh copy under the canonical name.
173
+ renameSync(commitDir, `${commitDir}.damaged-${process.pid}-${randomBytes(4).toString("hex")}`);
174
+ }
175
+ if (!complete) {
176
+ const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
177
+ const staging = join(soulsDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
178
+ try {
179
+ // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
180
+ await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
181
+ 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 });
182
+ if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
183
+ try { renameSync(staging, commitDir); }
184
+ catch (x) {
185
+ // Lost a race with a concurrent fetch of the same commit: theirs is the same bytes.
186
+ if (!(existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md")))) throw x;
187
+ rmSync(staging, { recursive: true, force: true });
188
+ }
189
+ } catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
190
+ }
191
+
192
+ // --- the "current" pointer + its stamp ---
193
+ const target = realpathSync(commitDir);
194
+ if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
195
+ const cur = readStamp();
196
+ if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
121
197
  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; }
198
+ }
199
+ return target;
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Member clones — where a workspace soul's `work: worktree|checkout` target lives
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** The taught member name of a repo key: the last path segment without `.git`
207
+ * (`github.com/northwind/platform` → `platform`). The same rule `oats onboard`
208
+ * and `oats sync` print (memberLabel). */
209
+ export function memberNameOf(key) {
210
+ return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
211
+ }
212
+
213
+ /** The convention path of a member's clone: `<deployment>/<member name>` — except a
214
+ * member called `agents`, which is cloned as `agents-repo/` because `<deployment>/agents/`
215
+ * is the instance root (design doc §4; matches onboard's cloneDirOf). */
216
+ export function conventionCloneDir(deployment, key) {
217
+ const name = memberNameOf(key);
218
+ return join(deployment, name === "agents" ? "agents-repo" : name);
219
+ }
220
+
221
+ /** Normalise a key an operator may have WRITTEN in `clones:` — the canonical key
222
+ * (`github.com/org/repo`), or any ref form parseRepoRef understands (`git:…`,
223
+ * `https://…`, `git@host:…`, `/abs/bare.git`) — to the canonical key. A key that
224
+ * parses no way is returned as written (it can then only match literally). */
225
+ function canonicalCloneKey(written) {
226
+ const s = String(written).trim();
227
+ if (s.startsWith("local/")) return s; // a local key is already canonical
228
+ for (const candidate of [s, `git:${s}`]) {
229
+ try { return parseRepoRef(candidate).key; } catch { /* next form */ }
230
+ }
231
+ return s;
232
+ }
233
+
234
+ /** The remote urls a clone carries (any remote, not only origin), parsed to their
235
+ * repo keys. Not a git repo → null. */
236
+ function cloneRemoteKeys(path) {
237
+ const git = (argv) => spawnSync("git", ["-C", path, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
238
+ const inside = git(["rev-parse", "--git-dir"]);
239
+ if (inside.status !== 0) return null;
240
+ const cfg = git(["config", "--get-regexp", "^remote\\..*\\.url$"]);
241
+ const keys = [];
242
+ if (cfg.status === 0) {
243
+ for (const line of cfg.stdout.split("\n")) {
244
+ const url = line.replace(/^\S+\s+/, "").trim();
245
+ if (!url) continue;
246
+ try { keys.push(parseRepoRef(url).key); } catch { keys.push(`?/${url}`); }
247
+ }
248
+ }
249
+ return keys;
250
+ }
251
+
252
+ /** Two repo keys name the same repository. Hosted keys compare literally; `local/<abs>`
253
+ * keys compare by realpath when the path exists (macOS tmpdir → /private/var…). */
254
+ function sameRepoKey(a, b) {
255
+ if (a === b) return true;
256
+ if (!a.startsWith("local/") || !b.startsWith("local/")) return false;
257
+ const real = (k) => { try { return realpathSync(k.slice("local/".length)); } catch { return k.slice("local/".length); } };
258
+ return real(a) === real(b);
259
+ }
260
+
261
+ /** Verify `path` is the clone of `key`: it exists, is a directory, is a Git repo, and
262
+ * one of its remotes parses to `key`. Anything else → E_CLONE_MISMATCH { path, expected,
263
+ * found } — the operator pointed the kernel at the wrong place; it never works there. */
264
+ export function verifyMemberClone(path, key, { via } = {}) {
265
+ const mismatch = (found, why) => err("E_CLONE_MISMATCH", `${via ? `${via}: ` : ""}${path} is not a clone of ${key}${why ? ` (${why})` : ""}${found && found.length ? ` — its remotes point at ${found.join(", ")}` : ""}`, { path, expected: key, found, via: via ?? null });
266
+ let st; try { st = statSync(path); } catch { throw mismatch(null, "the path does not exist"); }
267
+ if (!st.isDirectory()) throw mismatch(null, "not a directory");
268
+ const found = cloneRemoteKeys(path);
269
+ if (found === null) throw mismatch(null, "not a Git repository");
270
+ if (!found.some((k) => sameRepoKey(k, key))) throw mismatch(found, found.length ? "no remote names it" : "it has no remotes");
271
+ return path;
272
+ }
273
+
274
+ /**
275
+ * Where the work target of a PREPARED spawn lives — the member clone of the soul's
276
+ * repo on this machine (design doc §4; decision 9: the work target is the only thing
277
+ * that needs a clone). In order:
278
+ * 1. `explicit` (`--repo`) — the operator's word; relative to the deployment; not
279
+ * verified against the member (an explicit --repo may deliberately point elsewhere);
280
+ * 2. `oats-local.yaml` `clones: { <repo key>: <path> }` — the key as written is
281
+ * normalised through parseRepoRef so `git:…`, `https://…`, `git@…` spellings match;
282
+ * 3. `<deployment>/<member name>` (`agents` → `agents-repo`);
283
+ * 4. null — nothing on this machine.
284
+ * A path found by (2) or (3) is VERIFIED: a directory, a Git repo, one remote parsing to
285
+ * the member's key → else E_CLONE_MISMATCH. A `clones:` entry whose path is absent is a
286
+ * mismatch too (the operator named it; "missing" would hide the typo).
287
+ * Returns an absolute path or null.
288
+ */
289
+ export function resolveMemberClone(prepared, { explicit } = {}) {
290
+ const deployment = resolvePath(prepared?.deployment ?? process.cwd());
291
+ if (typeof explicit === "string" && explicit.trim()) return isAbsolute(explicit) ? resolvePath(explicit) : resolvePath(deployment, explicit);
292
+ const key = prepared?.soulEntry?.repoKey;
293
+ if (typeof key !== "string" || !key) return null;
294
+ const clones = prepared?.local?.clones;
295
+ if (clones && typeof clones === "object") {
296
+ for (const [written, value] of Object.entries(clones)) {
297
+ if (!sameRepoKey(canonicalCloneKey(written), key)) continue;
298
+ if (typeof value !== "string" || !value.trim()) throw err("E_CLONE_MISMATCH", `oats-local.yaml clones: ${written} must be a path`, { path: value, expected: key, found: null, via: "oats-local.yaml clones:" });
299
+ const path = isAbsolute(value) ? resolvePath(value) : resolvePath(deployment, value);
300
+ return verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` });
301
+ }
302
+ }
303
+ const convention = conventionCloneDir(deployment, key);
304
+ if (!existsSync(convention)) return null;
305
+ return verifyMemberClone(convention, key, { via: "convention path" });
306
+ }
307
+
308
+ /** The clone url of the soul's member as the workspace names it (for remedies). */
309
+ function memberUrlOf(prepared, key) {
310
+ for (const ref of prepared?.discovery?.workspace?.members || []) {
311
+ try { const p = parseRepoRef(ref); if (sameRepoKey(p.key, key)) return p.url; } catch { /* validated already */ }
312
+ }
313
+ if (prepared?.discovery?.standalone === true && prepared.discovery.key === key) return prepared.discovery.url ?? null;
314
+ return key.startsWith("local/") ? key.slice("local/".length) : `https://${key}.git`;
315
+ }
316
+
317
+ /** resolveMemberClone, or E_CLONE_MISSING naming BOTH ways to provide the clone
318
+ * (the convention path and a `clones:` entry) plus `--repo` for a one-off. */
319
+ export function requireMemberClone(prepared, { explicit } = {}) {
320
+ const found = resolveMemberClone(prepared, { explicit });
321
+ if (found) return found;
322
+ const key = prepared?.soulEntry?.repoKey ?? "<repo>";
323
+ const deployment = resolvePath(prepared?.deployment ?? process.cwd());
324
+ const convention = conventionCloneDir(deployment, key);
325
+ const url = memberUrlOf(prepared, key);
326
+ const soul = prepared?.soulEntry?.name ?? "<soul>";
327
+ const work = prepared?.soulEntry?.definition?.work ?? "worktree";
328
+ throw err("E_CLONE_MISSING", `soul ${soul} works in a ${work} of ${key}, and this machine has no clone of it — either \`git clone ${url} ${convention}\` (the convention: <deployment>/<member name>) or point oats-local.yaml at an existing clone: \`clones: { ${key}: <abs path> }\`; for a one-off pass --repo <path>`, { soul, repoKey: key, work, deployment, convention, url, remedies: { clone: `git clone ${url} ${convention}`, local: { clones: { [key]: "<abs path>" } }, flag: "--repo <path>" } });
124
329
  }
125
330
 
126
331
  /** The async half of a spawn: everything that touches the network. Returns a
@@ -133,7 +338,7 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
133
338
  const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
134
339
  const soulEntry = findSoulEntry(discovery, soulName);
135
340
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
136
- return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
341
+ return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
137
342
  }
138
343
 
139
344
  /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
@@ -571,6 +571,35 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
571
571
  return rows;
572
572
  }
573
573
 
574
+ /**
575
+ * soulDriftOf(instanceJson, discovery)
576
+ * → { name, repoKey, commit, current: { commit } | null, status: "current"|"moved"|"missing", reason? } | null
577
+ *
578
+ * Decision 17 for the SOUL SOURCE: a workspace spawn records `instance.json.workspace.soul =
579
+ * { repoKey, commit, team }` (the member and commit the soul was fetched from). The member row
580
+ * for `repoKey` in `discovery.members` is the current state: a different commit → "moved", the
581
+ * soul gone from the member → "missing" (reason "soul-absent"), no usable row → "missing"
582
+ * (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
583
+ * by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
584
+ * not reported missing. Returns null for a home without a recorded workspace soul (classic).
585
+ */
586
+ export function soulDriftOf(instanceJson, discovery) {
587
+ const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
588
+ if (!soul || typeof soul.repoKey !== "string") return null;
589
+ const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
590
+ const commit = typeof soul.commit === "string" ? soul.commit : null;
591
+ const members = Array.isArray(discovery?.members) ? discovery.members : [];
592
+ const member = members.find((m) => m && m.key === soul.repoKey) || null;
593
+ const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
594
+ const base = { name, repoKey: soul.repoKey, commit, team: soul.team ?? null };
595
+ if (!member || (!member.confirmed && !standaloneOwn) || typeof member.commit !== "string") {
596
+ return { ...base, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" };
597
+ }
598
+ const present = name === null || (Array.isArray(member.souls) && member.souls.some((s) => s && s.name === name));
599
+ if (!present) return { ...base, current: { commit: member.commit }, status: "missing", reason: "soul-absent" };
600
+ return { ...base, current: { commit: member.commit }, status: member.commit === commit ? "current" : "moved" };
601
+ }
602
+
574
603
  /** Read the modules recorded in an instance home (→ {} when absent). */
575
604
  export function recordedModules(home) {
576
605
  const file = join(resolve(home), "instance.json");
@@ -0,0 +1,117 @@
1
+ /** Operator-level capability commands — `oats <ns> <cmd>` run from a DEPLOYMENT
2
+ * directory (one holding `oats-local.yaml`), not from an instance home.
3
+ *
4
+ * Contract (docs/design/2026-09-23-workspace-module-contracts.md, "Post-0.25.0
5
+ * clarifications"): resolve exactly as `oats spawn --soul <name>` would —
6
+ * `prepareInstance(dir, soul)` → the soul's Resolution — find the module whose
7
+ * manifest `command` is the namespace, fetch that capability into the
8
+ * deployment's per-commit module store `<deployment>/.oats/modules/<cap>@<commit12>/`
9
+ * (the same store capability-defined agents use; see
10
+ * lib/instance-resolution.mjs#resolvePackageCapabilityAgent) and dispatch to that
11
+ * copy with the soul's merged payload as `OATS_SETTINGS`.
12
+ *
13
+ * Never "the newest instance's copy" (an instance is not an authority for the
14
+ * deployment) and never an unlocked cache read: trust is exactly spawn's —
15
+ * members by membership, packages by the lock's approval (`prepareInstance`
16
+ * already refuses E_PACKAGE_UNAPPROVED). A module in the Resolution IS active.
17
+ *
18
+ * `--soul` is required: without a soul there is no Resolution to answer from
19
+ * (E_BAD_ARGS naming --soul). Nothing here reads oats-config.yaml. */
20
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
21
+ import { dirname, join, resolve as resolvePath } from "node:path";
22
+ import { randomBytes } from "node:crypto";
23
+ import { oatsError } from "./errors.mjs";
24
+ import { loadLocal } from "./workspace.mjs";
25
+ import { packageRef, refForKey } from "./resolve.mjs";
26
+ import { MODULES_DIR } from "./materialize.mjs";
27
+ import * as defaultRemote from "./remote.mjs";
28
+ import { prepareInstance } from "./instance-resolution.mjs";
29
+
30
+ function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
31
+
32
+ /** Is `dir` (or an ancestor) a v2 deployment? → the loadLocal result or null when
33
+ * no oats-local.yaml is in reach (the caller then falls back to the classic chain).
34
+ * Any OTHER failure (a malformed oats-local.yaml → E_WORKSPACE_SCHEMA) propagates. */
35
+ export function deploymentOf(dir) {
36
+ try { return loadLocal(dir); }
37
+ catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
38
+ }
39
+
40
+ /** The repo ref a module's tree is fetched from, spelled as lib/remote.mjs accepts it:
41
+ * member → the member repoKey (`local/<abs>` → the abs path, else `git:<key>`);
42
+ * package → packageRef over the lock entry (catalog url or git source). */
43
+ export function moduleRef(module, lock, { catalog = null, remote = defaultRemote } = {}) {
44
+ const from = module?.from;
45
+ if (!from || typeof from !== "object") throw err("E_CAPABILITY_BROKEN", `module ${module?.name ?? "?"}: resolution carries no provenance`, { module: module?.name ?? null });
46
+ if (from.kind === "member") return refForKey(from.repoKey);
47
+ if (from.kind === "package") {
48
+ const entry = lock?.packages?.[from.package];
49
+ if (!entry || typeof entry !== "object") throw err("E_PACKAGE_MISSING", `module ${module.name}: package ${from.package} is not in the deployment's lock`, { module: module.name, id: from.package });
50
+ return packageRef(from.package, entry, catalog, remote);
51
+ }
52
+ throw err("E_CAPABILITY_BROKEN", `module ${module.name}: unknown provenance kind ${JSON.stringify(from.kind)}`, { module: module.name, kind: from.kind ?? null });
53
+ }
54
+
55
+ /** `<deployment>/.oats/modules/<cap>@<commit12>` — the per-commit store path of a module. */
56
+ export function moduleStoreDir(deployment, module) {
57
+ return join(deployment, MODULES_DIR, `${module.name}@${String(module.from.commit).slice(0, 12)}`);
58
+ }
59
+
60
+ /** Ensure the module's capability tree is in the deployment store; returns its dir.
61
+ * A tree already there (oats.json present) is reused — the commit is in the name,
62
+ * so it cannot be stale. The fetch lands in a sibling staging dir and is renamed
63
+ * into place, so a failed or interrupted fetch never leaves a half tree that a
64
+ * later call would trust. */
65
+ export async function ensureModuleTree(deployment, module, lock, { catalog = null, remote = defaultRemote, remoteOptions, fetch = defaultRemote.fetchRemoteTree } = {}) {
66
+ const store = join(deployment, MODULES_DIR);
67
+ const dir = moduleStoreDir(deployment, module);
68
+ if (existsSync(join(dir, "oats.json"))) return dir;
69
+ const ref = moduleRef(module, lock, { catalog, remote });
70
+ if (typeof module.dir !== "string" || !module.dir) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: resolution records no capability directory`, { module: module.name });
71
+ mkdirSync(store, { recursive: true });
72
+ const staging = join(store, `.staging-${module.name}-${process.pid}-${randomBytes(4).toString("hex")}`);
73
+ try {
74
+ await fetch(ref, module.from.commit, module.dir, staging, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
75
+ if (!existsSync(join(staging, "oats.json"))) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: ${module.dir} at ${String(module.from.commit).slice(0, 12)} has no oats.json`, { module: module.name, dir: module.dir, commit: module.from.commit });
76
+ // A concurrent call may have won; its tree is the same commit — keep it.
77
+ if (existsSync(join(dir, "oats.json"))) { rmSync(staging, { recursive: true, force: true }); return dir; }
78
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); // a half tree from an interrupted direct write
79
+ renameSync(staging, dir);
80
+ return dir;
81
+ } catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
82
+ }
83
+
84
+ /**
85
+ * Resolve an operator-level command namespace from a deployment directory.
86
+ *
87
+ * contextDir — where the operator stands (oats-local.yaml is found walking up)
88
+ * namespace — the `<ns>` the operator typed (`okf`)
89
+ * soulName — the value of --soul; REQUIRED (undefined/true/"" → E_BAD_ARGS)
90
+ *
91
+ * → { deployment, soul: { name, repoKey, commit, team }, module, manifest, commands,
92
+ * settings: <resolution.payloads[cap] or {}>, resolution, prepared,
93
+ * ensureTree(): Promise<dir> } — or null when no module of the soul's
94
+ * resolution claims the namespace (the caller answers E_UNKNOWN_COMMAND).
95
+ * Two modules claiming one namespace → E_DUPLICATE_NAMESPACE. Everything
96
+ * prepareInstance can throw (E_SOUL_UNKNOWN, E_PACKAGE_UNAPPROVED,
97
+ * E_REMOTE_UNREADABLE, …) propagates untouched.
98
+ */
99
+ export async function resolveOperatorDispatch(contextDir, namespace, soulName, { remoteOptions, remote, catalog = null, fetch, prepare = prepareInstance } = {}) {
100
+ if (typeof namespace !== "string" || !namespace) throw err("E_BAD_ARGS", "a command namespace is required");
101
+ if (typeof soulName !== "string" || !soulName.trim()) {
102
+ throw err("E_BAD_ARGS", `oats ${namespace}: outside an instance home a capability command resolves as a spawn would — pass --soul <name> (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
103
+ }
104
+ const prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
105
+ const { resolution, lock } = prepared;
106
+ const deployment = prepared.deployment ?? dirname(loadLocal(contextDir).path);
107
+ const claimants = (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
108
+ if (!claimants.length) return null;
109
+ if (claimants.length > 1) throw err("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${namespace}": ${claimants.map((m) => m.name).join(", ")}`, { namespace, modules: claimants.map((m) => m.name) });
110
+ const module = claimants[0];
111
+ const payload = resolution.payloads?.[module.name];
112
+ const settings = payload && typeof payload === "object" ? { ...payload } : {};
113
+ return {
114
+ deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared,
115
+ ensureTree: () => ensureModuleTree(deployment, module, lock, { catalog, remote, remoteOptions: remoteOptions ?? prepared.remoteOptions, fetch }),
116
+ };
117
+ }
package/lib/packages.mjs CHANGED
@@ -43,6 +43,12 @@
43
43
  * the executable target whose bytes are digested. Only command targets are
44
44
  * digested — skills, injects and other files are covered by `integrity`.
45
45
  *
46
+ * `executablesDigestAt(remote, ref, commit, path, capabilities?)` is the ONE shared
47
+ * computation of that digest over a remote tree at a commit: `oats sync` approves
48
+ * what it returns, `resolveSoul` requires equality with `approved.executables` at
49
+ * spawn (E_PACKAGE_UNAPPROVED reason "digest-mismatch"). An edited lock — same
50
+ * id/version, different commit, copied approval — can therefore never materialize.
51
+ *
46
52
  * Deprecated shims: every name the 0.24 kernel still imports from this module
47
53
  * is exported below as a thin function throwing E_REMOVED so nothing breaks at
48
54
  * import time; callers are deleted in the next phase.
@@ -370,6 +376,61 @@ export async function readPackageTree(remote, remoteRef, commit, path, { depth =
370
376
  return { manifests };
371
377
  }
372
378
 
379
+ /**
380
+ * The executables digest of a locked package AT A COMMIT, read over the remote — the ONE definition
381
+ * `oats sync` (approval) and `resolveSoul` (the gate at spawn) share, so what was approved is exactly what
382
+ * is checked. `remote` is a contract-§1 remote (default lib/remote.mjs; `remoteOptions` bound here).
383
+ *
384
+ * executablesDigestAt(remote, ref, commit, path, capabilities?, { remoteOptions } = {})
385
+ * → { digest: "sha256-…", executables: [{ capability, kind, name, target }], capabilities: [<names>] } (frozen)
386
+ *
387
+ * `capabilities` (optional, the lock entry's list) is checked against what `<path>/oats-package.json`
388
+ * declares at `commit`: a mismatch is E_PACKAGE_INTEGRITY { why: "capabilities", listed, locked } — a lock
389
+ * whose capability list drifted from the tree it names is not describing that tree.
390
+ *
391
+ * Cached per process by (remote identity, repo key, commit, path): a commit is content-addressed, so the
392
+ * tree — and its digest — can never change under the same OID. The cache is keyed on the remote OBJECT
393
+ * (WeakMap): a test's fake remote gets its own cache and never sees another test's tree; the kernel's
394
+ * default remote keeps one cache for the process (sync → many spawns of the same lock).
395
+ */
396
+ const digestCache = new WeakMap();
397
+ export async function executablesDigestAt(remote, ref, commit, path, capabilities = null, { remoteOptions } = {}) {
398
+ if (!remote || typeof remote.readRemoteFile !== "function" || typeof remote.listRemoteTree !== "function") {
399
+ throw new TypeError("executablesDigestAt: remote must provide readRemoteFile()/listRemoteTree() (module contract §1)");
400
+ }
401
+ if (typeof commit !== "string" || !OID_RE.test(commit)) throw oatsError("E_PACKAGE_INTEGRITY", `executablesDigestAt: commit must be a full 40-hex OID, got ${JSON.stringify(commit)}`, { commit, path });
402
+ if (capabilities !== null && (!Array.isArray(capabilities) || capabilities.some((c) => typeof c !== "string"))) throw new TypeError("executablesDigestAt: capabilities must be an array of names or null");
403
+ const parse = typeof remote.parseRepoRef === "function" ? remote.parseRepoRef : defaultRemote.parseRepoRef;
404
+ let repoKey; try { repoKey = parse(ref).key; } catch { repoKey = String(ref); }
405
+ const cacheKey = `${repoKey}\0${commit}\0${path}`;
406
+ let perRemote = digestCache.get(remote);
407
+ if (!perRemote) { perRemote = new Map(); digestCache.set(remote, perRemote); }
408
+ let pending = perRemote.get(cacheKey);
409
+ if (!pending) {
410
+ pending = (async () => {
411
+ const tree = await readPackageTree(remote, ref, commit, path, { remoteOptions });
412
+ const digest = executablesDigest(tree);
413
+ const executables = [];
414
+ for (const m of [...tree.manifests].sort((a, b) => byCodepoint(String(a.name), String(b.name)))) {
415
+ for (const x of manifestExecutables(m.manifest)) executables.push({ capability: m.name, kind: x.kind, name: x.name, target: x.target });
416
+ }
417
+ return Object.freeze({ digest, executables: Object.freeze(executables.map((x) => Object.freeze(x))), capabilities: Object.freeze(tree.manifests.map((m) => m.name).sort(byCodepoint)) });
418
+ })();
419
+ perRemote.set(cacheKey, pending);
420
+ // A failed read is not cached: the next caller retries (a transient remote failure must not pin an error).
421
+ pending.catch(() => { if (perRemote.get(cacheKey) === pending) perRemote.delete(cacheKey); });
422
+ }
423
+ const result = await pending;
424
+ if (capabilities !== null) {
425
+ const locked = [...capabilities].sort(byCodepoint);
426
+ const listed = result.capabilities;
427
+ if (locked.length !== listed.length || locked.some((c, i) => c !== listed[i])) {
428
+ throw oatsError("E_PACKAGE_INTEGRITY", `the lock lists capabilities [${locked.join(", ")}] for ${path} at ${commit.slice(0, 12)}, but the package there declares [${listed.join(", ")}]`, { why: "capabilities", commit, path, listed: [...listed], locked });
429
+ }
430
+ }
431
+ return result;
432
+ }
433
+
373
434
  // ---------- resolvePackages ----------
374
435
 
375
436
  function assertDigest(what, value, details) {
@@ -454,7 +515,7 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
454
515
  }
455
516
  // A recorded approval must describe THESE executables, not merely any well-formed digest.
456
517
  if (old.approved) {
457
- const executables = executablesDigest(await readPackageTree(remote, req.remoteRef, obs.commit, old.path));
518
+ const { digest: executables } = await executablesDigestAt(remote, req.remoteRef, obs.commit, old.path);
458
519
  if (executables !== old.approved.executables) {
459
520
  throw oatsError("E_PACKAGE_UNAPPROVED",
460
521
  `packages.${id} ${version}: the recorded approval ${old.approved.executables} does not match the package's executables ${executables} — approve again`,