@awebai/oats 0.25.0 → 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.
package/lib/core.mjs CHANGED
@@ -6131,7 +6131,13 @@ function* spawnBody(root, agent, o = {}) {
6131
6131
  if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
6132
6132
  if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
6133
6133
  const launch = o.launch !== false;
6134
- const repoAbs = resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
6134
+ // Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
6135
+ // boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
6136
+ // is a plain directory with member clones beside it, not a Git checkout. It is
6137
+ // the execution/config context too; no Git identity is required or recorded.
6138
+ const preparedDeployment = o.prepared && work === "workspace" && typeof o.prepared.deployment === "string" && o.prepared.deployment ? resolve(o.prepared.deployment) : undefined;
6139
+ if (preparedDeployment !== undefined && !(existsSync(preparedDeployment) && statSync(preparedDeployment).isDirectory())) throw oatsError("E_BAD_ARGS", `workspace mode: the deployment directory ${preparedDeployment} (where oats-local.yaml lives) is not a directory`);
6140
+ const repoAbs = preparedDeployment ?? resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
6135
6141
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
6136
6142
  // Launch selection: a named configuration (explicit, or the soul's
6137
6143
  // launch-config default), or none; the runtime and model follow from it.
@@ -6564,7 +6570,7 @@ function* spawnBody(root, agent, o = {}) {
6564
6570
  const preparedCapabilities = o.prepared ? o.prepared.resolution.modules.map((m) => ({ name: m.name, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}` })) : null;
6565
6571
  const preparedSkills = o.prepared ? o.prepared.resolution.modules.flatMap((m) => (m.manifest?.skills || []).filter((s) => typeof s === "string" && s).map((s) => ({ name: basename(s.replace(/\/+$/, "")), source: `module:${m.name}` }))) : [];
6566
6572
  return deliver({
6567
- ...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
6573
+ ...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, declRevision: o.prepared.resolution.declRevision ?? null, payloadRevision: o.prepared.resolution.payloadRevision ?? null, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
6568
6574
  spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
6569
6575
  subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
6570
6576
  decision, preflight,
@@ -6684,7 +6690,29 @@ function* spawnBody(root, agent, o = {}) {
6684
6690
  initializeNativeHistory(home);
6685
6691
 
6686
6692
  // Body: the soul is linked for reference, while instructions are a generated instance-local view.
6687
- symlinkSync(soulDir, join(home, "soul"));
6693
+ // The home's soul link. A workspace soul (o.prepared) lives in the per-commit cache
6694
+ // agents/<name>/souls/<commit12>/ and agents/<name>/soul is only the kernel-owned
6695
+ // "current" POINTER (a symlink swapped by every ensureWorkspaceSoul, including a
6696
+ // preview's). The home links ITS commit's directory by realpath — never the pointer —
6697
+ // so a later fetch can move "current" without changing anything under a running
6698
+ // instance (decision 7); the OKF hook's owner pin (realpath of <home>/soul) stays valid.
6699
+ let homeSoulTarget = soulDir;
6700
+ if (o.prepared?.soulEntry?.commit) {
6701
+ const perCommit = join(agent._dir, "souls", String(o.prepared.soulEntry.commit).slice(0, 12));
6702
+ if (existsSync(join(perCommit, "soul.yaml"))) homeSoulTarget = realpathSync(perCommit);
6703
+ }
6704
+ if (homeSoulTarget === soulDir) {
6705
+ // Not prepared (or the cache entry is absent): still never link a swappable pointer —
6706
+ // when agents/<name>/soul is the kernel's pointer into souls/, link what it shows now.
6707
+ try {
6708
+ if (lstatSync(soulDir).isSymbolicLink()) {
6709
+ const real = realpathSync(soulDir), soulsReal = realpathSync(join(dirname(soulDir), "souls"));
6710
+ const rel = relative(soulsReal, real);
6711
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel) && !rel.includes(sep)) homeSoulTarget = real;
6712
+ }
6713
+ } catch { /* absent souls/ or unreadable link: link the classic soul dir */ }
6714
+ }
6715
+ symlinkSync(homeSoulTarget, join(home, "soul"));
6688
6716
  if (!o.prepared) { writeFileSync(join(home, "AGENTS.md"), composition.text); symlinkSync("AGENTS.md", join(home, "CLAUDE.md")); }
6689
6717
  else if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
6690
6718
 
@@ -6839,13 +6867,22 @@ function* spawnBody(root, agent, o = {}) {
6839
6867
  symlinkSync(resolve(o.workDir), join(home, "work"));
6840
6868
  branch = shTry(`git -C ${shq(o.workDir)} rev-parse --abbrev-ref HEAD`);
6841
6869
  } else if (work === "workspace") {
6842
- // Cross-repo coordinator: ./work is the TEAM SCOPE (deployment boundary), not
6843
- // a repo — member repos are read-context; repo edits are routed, not made.
6844
- // Requires a declared boundary: config team: scope, else the workspace scope.
6870
+ // Cross-repo coordinator: ./work is the deployment boundary, not a repo — member
6871
+ // repos are read-context; repo edits are routed, not made.
6872
+ // workspace model (o.prepared): the directory holding oats-local.yaml
6873
+ // (prepared.deployment — the taught <name>-workspace/, member clones beside it);
6874
+ // classic: config team: scope, else the workspace-scope oats-config.yaml.
6845
6875
  const resolvedCfgEarly = composition.resolved;
6846
- const wsRoot = resolvedCfgEarly.team?.scope
6876
+ const wsRoot = preparedDeployment
6877
+ || resolvedCfgEarly.team?.scope
6847
6878
  || resolvedCfgEarly.chain?.find((c) => c._level !== homedir())?._level;
6848
- if (!wsRoot) { rmSync(home, { recursive: true, force: true }); throw new Error(`workspace mode needs a declared boundary — add a "team:" block (or a workspace-scope oats-config.yaml) so ./work has a root`); }
6879
+ if (!wsRoot) {
6880
+ rmSync(home, { recursive: true, force: true });
6881
+ const remedy = o.prepared
6882
+ ? `add oats-local.yaml (workspace: <ref> or standalone: <ref>) to the deployment directory so ./work has a root`
6883
+ : `add a "team:" block (or a workspace-scope oats-config.yaml) so ./work has a root`;
6884
+ throw new Error(`workspace mode needs a declared boundary — ${remedy}`);
6885
+ }
6849
6886
  symlinkSync(resolve(wsRoot), join(home, "work"));
6850
6887
  branch = undefined; // no repo identity: the workspace is not a git tree
6851
6888
  } else {
@@ -16,8 +16,8 @@
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";
@@ -98,29 +98,104 @@ export function findSoulEntry(discovery, name) {
98
98
  return hits[0];
99
99
  }
100
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. */
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). */
106
144
  export async function ensureWorkspaceSoul(prepared, agentsRoot) {
107
145
  const e = prepared.soulEntry;
108
146
  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);
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) {
121
196
  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; }
197
+ }
198
+ return target;
124
199
  }
125
200
 
126
201
  /** The async half of a spawn: everything that touches the network. Returns a
@@ -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`,