@awebai/oats 0.27.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
@@ -16,19 +16,19 @@
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, realpathSync, readlinkSync, copyFileSync, chmodSync } from "node:fs";
20
- import { join, resolve as resolvePath, dirname, basename, relative, isAbsolute, sep } from "node:path";
19
+ import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
20
+ import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from "node:path";
21
21
  import { oatsError } from "./errors.mjs";
22
22
  import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace, observeSoulLabels } from "./workspace.mjs";
23
- import { resolveSoul, packageRef, memberRef, manifestDefaultsPayload, mergePayload, payloadOrigins, revisionOf, teamsOf, kernelCompatibility, RESOLUTION_API, SLOTS } from "./resolve.mjs";
24
- import { declaredSettings, settingValueProblems } from "./capability-contract.mjs";
23
+ import { resolveSoul, teamsOf, kernelCompatibility } from "./resolve.mjs";
24
+ import { declaredSettings } from "./capability-contract.mjs";
25
25
  import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
26
26
  import { fetchRemoteTree } from "./remote.mjs";
27
27
  import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
29
  import { spawnSync } from "node:child_process";
30
30
  import { randomBytes } from "node:crypto";
31
- import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
31
+ import { readLock, LOCK_FILE, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
32
32
  import * as defaultRemote from "./remote.mjs";
33
33
  import { parseRepoRef } from "./remote.mjs";
34
34
 
@@ -77,30 +77,58 @@ export function parseProviderFlags(pairs) {
77
77
  return JSON.parse(JSON.stringify(out));
78
78
  }
79
79
 
80
- /** Find the soul named `name` in a discovery: confirmed members first (by
81
- * `repo/name` or bare name when unique), then external souls. */
80
+ /** The qualified name of a discovered soul: `<package>/<soul>` for a package soul,
81
+ * `<member name>/<soul>` for a member's, `<source repo name>/<soul>` for an external one. */
82
+ export function qualifiedSoulName(entry) {
83
+ if (typeof entry?.package === "string") return `${entry.package}/${entry.name}`;
84
+ return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
85
+ }
86
+
87
+ /** Find the soul named `name` in a discovery: confirmed members, external souls and package
88
+ * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
89
+ * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
90
+ * or an external source, `<package>/<soul>` a package soul. */
82
91
  export function findSoulEntry(discovery, name) {
83
92
  const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
84
93
  const hits = [];
85
94
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
86
95
  // workspace could not be read); resolveSoul admits exactly that case.
87
96
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
97
+ const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
88
98
  for (const m of discovery.members || []) {
89
99
  if (!m.confirmed && m.key !== standaloneOwn) continue;
90
100
  for (const s of m.souls || []) {
91
- if (s.name !== soulPart) continue;
92
- if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
101
+ if (s.name !== soulPart || !memberMatches(m.key)) continue;
93
102
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
94
103
  }
95
104
  }
96
105
  for (const x of discovery.external || []) {
97
106
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
98
107
  }
99
- if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members or external souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key) });
100
- if (hits.length > 1) throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} exists in ${hits.length} repos; name it as <repo>/${soulPart}`, { name, repos: hits.map((h) => h.repoKey) });
108
+ for (const s of discovery.packageSouls || []) {
109
+ if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
110
+ }
111
+ if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
112
+ if (hits.length > 1) {
113
+ const qualified = hits.map(qualifiedSoulName);
114
+ throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} is ${hits.length} souls (${qualified.join(", ")}); name one of them`, { name, repos: hits.map((h) => h.repoKey), qualified });
115
+ }
116
+ if (Array.isArray(hits[0].collides)) throw err("E_SOUL_AMBIGUOUS", `package souls ${hits[0].collides.join(" and ")} would share the agent directory agents/${hits[0].agentName}/ — keep one of the packages in the workspace's packages:`, { name, agentDir: hits[0].agentName, qualified: hits[0].collides });
101
117
  return hits[0];
102
118
  }
103
119
 
120
+ /** The agents-root directory of a discovered soul: a package soul's is `<package>--<soul>`
121
+ * (packageSoulAgentName), every other soul's is its name. */
122
+ export function agentDirOf(entry) { return typeof entry?.agentName === "string" ? entry.agentName : entry?.name; }
123
+
124
+ /** `oats-local.yaml` `souls.disabled` (not run on this machine): an entry names a soul by its
125
+ * bare name (every soul of that name) or its qualified name. → the matching entry or null. */
126
+ export function disabledEntry(local, soulEntry) {
127
+ const list = Array.isArray(local?.souls?.disabled) ? local.souls.disabled : [];
128
+ const qualified = qualifiedSoulName(soulEntry);
129
+ return list.find((d) => d === soulEntry.name || d === qualified) ?? null;
130
+ }
131
+
104
132
  /**
105
133
  * A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
106
134
  * state, not frozen composition: the soul's labels are read from its repository NOW and each
@@ -115,8 +143,10 @@ export function findSoulEntry(discovery, name) {
115
143
  export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
116
144
  const recorded = (reason, error) => ({ teams: Array.isArray(meta?.teams) ? meta.teams : null, source: "recorded", reason, ...(error ? { error } : {}) });
117
145
  const soul = meta?.workspace?.soul;
118
- // A capability agent's soul is its providing module's: no team labels, nothing to re-read.
146
+ // A home without a workspace soul record (a pre-0.29 capability agent's) has nothing to re-read.
119
147
  if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
148
+ // A package soul's labels are pinned with the package: the spawn-time record is its answer.
149
+ if (soul.package && typeof soul.package === "object") return recorded("package-soul");
120
150
  const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
121
151
  try {
122
152
  const found = loadLocal(deployment);
@@ -162,9 +192,13 @@ function swapSoulPointer(soulDir, target) {
162
192
  /** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
163
193
  async function fetchSoulSource(prepared, dest) {
164
194
  const e = prepared.soulEntry;
165
- const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
195
+ const ref = typeof e.ref === "string" ? e.ref : e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
166
196
  // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
167
- await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
197
+ const { digest } = await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: SOUL_ALIAS_SYMLINK });
198
+ // A package soul is what the lock recorded at sync: the same bytes at the locked commit.
199
+ if (typeof e.package === "string" && digest !== e.digest) {
200
+ throw err("E_PACKAGE_INTEGRITY", `package soul ${e.qualifiedName} v${e.version} @ ${String(e.commit).slice(0, 12)}: content digest ${digest} does not match the locked ${e.digest} — run \`oats sync\` (a lock that was edited: remove the entry and sync again)`, { package: e.package, soul: e.name, version: e.version, commit: e.commit, why: "soul-digest", locked: e.digest, observed: digest });
201
+ }
168
202
  if (!existsSync(join(dest, "soul.yaml")) || !existsSync(join(dest, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
169
203
  if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
170
204
  }
@@ -176,7 +210,7 @@ async function fetchSoulSource(prepared, dest) {
176
210
  * that temporary copy; `fetched` says whether the preview had to fetch. */
177
211
  export async function previewWorkspaceSoul(prepared, agentsRoot) {
178
212
  const e = prepared.soulEntry;
179
- const commitDir = join(agentsRoot, e.name, SOULS_DIR, commit12(e.commit));
213
+ const commitDir = join(agentsRoot, agentDirOf(e), SOULS_DIR, commit12(e.commit));
180
214
  if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
181
215
  const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
182
216
  const cleanup = () => rmSync(tmp, { recursive: true, force: true });
@@ -202,7 +236,7 @@ export async function previewWorkspaceSoul(prepared, agentsRoot) {
202
236
  * Returns the PER-COMMIT directory (what a home should link). */
203
237
  export async function ensureWorkspaceSoul(prepared, agentsRoot) {
204
238
  const e = prepared.soulEntry;
205
- const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
239
+ const agentDir = join(agentsRoot, agentDirOf(e)); const soulDir = join(agentDir, "soul");
206
240
  const soulsDir = join(agentDir, SOULS_DIR);
207
241
  const stamp = join(agentDir, SOUL_SOURCE_STAMP);
208
242
  const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
@@ -248,7 +282,8 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
248
282
  if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
249
283
  const cur = readStamp();
250
284
  if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
251
- writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
285
+ const pkg = typeof e.package === "string" ? { package: e.package, version: e.version } : {};
286
+ writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, ...pkg, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
252
287
  }
253
288
  return target;
254
289
  }
@@ -345,18 +380,25 @@ export function resolveMemberClone(prepared, { explicit } = {}) {
345
380
  if (typeof explicit === "string" && explicit.trim()) return isAbsolute(explicit) ? resolvePath(explicit) : resolvePath(deployment, explicit);
346
381
  const key = prepared?.soulEntry?.repoKey;
347
382
  if (typeof key !== "string" || !key) return null;
348
- const clones = prepared?.local?.clones;
383
+ return memberCloneOf(deployment, prepared?.local, key).path;
384
+ }
385
+
386
+ /** Steps 2–4 of resolveMemberClone for one member, naming the rule that found it (feature desktop-facts):
387
+ * → { path, rule: "clones" | "convention" } | { path: null, rule: null }; a named or conventional path that
388
+ * is not the member's clone is E_CLONE_MISMATCH, as for a spawn. */
389
+ export function memberCloneOf(deployment, local, key) {
390
+ const clones = local?.clones;
349
391
  if (clones && typeof clones === "object") {
350
392
  for (const [written, value] of Object.entries(clones)) {
351
393
  if (!sameRepoKey(canonicalCloneKey(written), key)) continue;
352
394
  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:" });
353
395
  const path = isAbsolute(value) ? resolvePath(value) : resolvePath(deployment, value);
354
- return verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` });
396
+ return { path: verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` }), rule: "clones" };
355
397
  }
356
398
  }
357
399
  const convention = conventionCloneDir(deployment, key);
358
- if (!existsSync(convention)) return null;
359
- return verifyMemberClone(convention, key, { via: "convention path" });
400
+ if (!existsSync(convention)) return { path: null, rule: null };
401
+ return { path: verifyMemberClone(convention, key, { via: "convention path" }), rule: "convention" };
360
402
  }
361
403
 
362
404
  /** The clone url of the soul's member as the workspace names it (for remedies). */
@@ -382,6 +424,17 @@ export function requireMemberClone(prepared, { explicit } = {}) {
382
424
  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>" } });
383
425
  }
384
426
 
427
+ /** Would a spawn of this discovered soul be refused before it touches this machine (feature desktop-facts)?
428
+ * The same checks prepareInstance makes — souls.disabled, then the soul's resolution (team conflicts, slot
429
+ * conflicts, missing or private capabilities, compatibility …) — without spawning anything.
430
+ * → { spawnable: true, problem: null } | { spawnable: false, problem: { code, message } } */
431
+ export async function soulSpawnability(local, discovery, lock, soulEntry, { remoteOptions, remote } = {}) {
432
+ const disabled = disabledEntry(local, soulEntry);
433
+ if (disabled !== null) return { spawnable: false, problem: { code: "E_SOUL_DISABLED", message: `disabled on this machine (oats-local.yaml souls.disabled: ${disabled})` } };
434
+ try { await resolveSoul(discovery, soulEntry, { local, lock, spawn: {}, remoteOptions, remote }); return { spawnable: true, problem: null }; }
435
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return { spawnable: false, problem: { code: e.code, message: e.message } }; throw e; }
436
+ }
437
+
385
438
  /** The async half of a spawn: everything that touches the network. Returns a
386
439
  * PREPARED object that `spawnInstance` consumes synchronously. */
387
440
  export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
@@ -389,8 +442,10 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
389
442
  const local = found.local;
390
443
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
391
444
  const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
392
- const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
445
+ const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
393
446
  const soulEntry = findSoulEntry(discovery, soulName);
447
+ const disabled = disabledEntry(local, soulEntry);
448
+ if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
394
449
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
395
450
  return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
396
451
  }
@@ -402,6 +457,11 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
402
457
  const ACCESS_REASONS = new Set(["auth", "not-found"]);
403
458
  const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
404
459
 
460
+ /** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
461
+ export function deploymentLock(deployment) {
462
+ return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
463
+ }
464
+
405
465
  /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
406
466
  * repo must declare that workspace (oats-membership.yaml). A lone repo with no
407
467
  * backlink is not a member of anything and never becomes a capability source. */
@@ -424,21 +484,23 @@ function requireMembership(repo, ref) {
424
484
  * readable in this access context (auth / not-found).
425
485
  * A transient failure reading the host (network, timeout) is rethrown as-is.
426
486
  */
427
- export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
487
+ export async function discoverOrStandalone(local, { lock, deployment, remoteOptions, remote } = {}) {
428
488
  const ro = { remoteOptions, remote };
489
+ // Package souls are listed from the deployment's lock (the `lock` given, else `<deployment>/oats-lock.json`).
490
+ if (lock === undefined) lock = typeof deployment === "string" ? deploymentLock(deployment) : null;
429
491
  if (typeof local.standalone === "string" && local.standalone) {
430
492
  const repo = await discoverRepo(local.standalone, ro);
431
493
  requireMembership(repo, local.standalone);
432
494
  return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
433
495
  }
434
- try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
496
+ try { return await discoverWorkspace(local.workspace, { local, lock, ...ro }); }
435
497
  catch (e) {
436
498
  if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
437
499
  // The ref is a repository without oats-workspace.yaml: is it a member whose
438
500
  // workspace we cannot read? Then the standalone view is what the operator gets.
439
501
  const repo = await discoverRepo(local.workspace, ro);
440
502
  if (!repo.membership) throw e;
441
- try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
503
+ try { return await discoverWorkspace(repo.membership.workspace, { local, lock, ...ro }); }
442
504
  catch (inner) {
443
505
  // Only an ACCESS failure on the host means "standalone"; anything else
444
506
  // (network, timeout, a broken workspace file) is the caller's to see.
@@ -451,115 +513,9 @@ export async function discoverOrStandalone(local, { remoteOptions, remote } = {}
451
513
  /** Materialize a prepared resolution into `home`. Called by spawnInstance after
452
514
  * the home directory exists and before the harness is launched. */
453
515
  export async function materializePrepared(prepared, home) {
454
- const localModules = prepared.localModules || null;
455
- // A capability agent's module copied from its anchor's verified copy (prepareCapabilityAgent):
456
- // materialize still checks the copy's digest against the one the anchor recorded.
457
- const fetch = localModules ? async (ref, commit, dir, dest, options) => {
458
- const src = localModules[basename(dest)];
459
- if (!src) return defaultRemote.fetchRemoteTree(ref, commit, dir, dest, options);
460
- copyLocalModule(src, dest);
461
- return { digest: defaultRemote.contentDigest(dest, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK }) };
462
- } : undefined;
463
- return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir, ...(fetch ? { fetch } : {}) });
464
- }
465
- /** Copy a materialized module tree (regular files, directories, the CLAUDE.md alias) — nothing else. */
466
- function copyLocalModule(src, dest, rel = "") {
467
- mkdirSync(dest, { recursive: true });
468
- for (const name of readdirSync(src).sort()) {
469
- const from = join(src, name), to = join(dest, name), at = rel ? `${rel}/${name}` : name, st = lstatSync(from);
470
- if (st.isSymbolicLink()) {
471
- if (!defaultRemote.OATS_ALIAS_SYMLINK(at) || readlinkSync(from) !== "AGENTS.md") throw err("E_REMOTE_TREE_UNSAFE", `${from} is a symlink`, { path: from, why: "symlink" });
472
- symlinkSync("AGENTS.md", to);
473
- } else if (st.isDirectory()) copyLocalModule(from, to, at);
474
- else if (st.isFile()) { copyFileSync(from, to); chmodSync(to, (st.mode & 0o111) ? 0o755 : 0o644); }
475
- else throw err("E_REMOTE_TREE_UNSAFE", `${from} is not a regular file`, { path: from, why: "device" });
476
- }
516
+ return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir });
477
517
  }
478
518
 
479
- /**
480
- * The prepared resolution of a CAPABILITY AGENT (a capability's `agents:` soul, e.g.
481
- * OKF's memory-harvest worker) — lead decision c3 Q1. It composes its providing
482
- * capability's module and NOTHING else: no workspace defaults, no knowledge,
483
- * messaging or tasks module, so no provider hook of any slot runs for it, and the
484
- * providing module's own hooks do not run either (a manifest has no way to declare
485
- * them for an agent). A knowledge-layer provider's inject is left out: a service
486
- * agent carries no memory protocol.
487
- *
488
- * The module comes from `agent._manifestSource` — the instance home whose module
489
- * copy declared the agent (the --parent/--relative-to anchor first): its recorded
490
- * `modules[<cap>]` (copied from that home's verified copy, pinned to the recorded
491
- * digest) and its recorded `providers[<cap>]` payload. Without such a home (the
492
- * source instance is gone), `pkg` — the deployment lock's package entry that
493
- * declares the agent (resolvePackageCapabilityAgent) — supplies the module, and the
494
- * payload is the manifest defaults ⊕ the workspace's messaging payload (for a
495
- * messaging-layer provider) ⊕ oats-local.yaml `settings.<cap>`.
496
- */
497
- export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg = null, remoteOptions, catalog = null, remote = defaultRemote } = {}) {
498
- const found = loadLocal(contextDir);
499
- const local = found.local;
500
- const deployment = dirname(found.path);
501
- const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
502
- const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
503
- const cap = agent.capability;
504
- let module, payload, origins, localModules = null;
505
- const anchor = pkg ? null : agent._manifestSource;
506
- if (anchor) {
507
- let meta;
508
- try { meta = JSON.parse(readFileSync(join(anchor, "instance.json"), "utf8")); } catch { meta = null; }
509
- const recorded = meta?.modules?.[cap];
510
- const moduleDir = join(anchor, MODULES_DIR, cap);
511
- if (!recorded?.from || typeof recorded.digest !== "string" || !existsSync(join(moduleDir, "oats.json"))) {
512
- throw err("E_CAPABILITY_BROKEN", `capability agent ${agent.name}: ${anchor} does not record a verified copy of ${cap} (instance.json modules.${cap} with a digest, and ${join(MODULES_DIR, cap)}); respawn that instance`, { capability: cap, anchor });
513
- }
514
- const manifest = JSON.parse(readFileSync(join(moduleDir, "oats.json"), "utf8"));
515
- module = { name: cap, from: { ...recorded.from }, manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, digest: recorded.digest };
516
- payload = meta.providers?.[cap] && typeof meta.providers[cap] === "object" ? JSON.parse(JSON.stringify(meta.providers[cap])) : {};
517
- origins = Object.fromEntries(Object.keys(payload).map((k) => [`/${k.replace(/~/g, "~0").replace(/\//g, "~1")}`, { kind: "anchor", at: `${join(anchor, "instance.json")}#/providers/${cap}` }]));
518
- localModules = { [cap]: moduleDir };
519
- } else if (pkg) {
520
- const manifest = pkg.manifest;
521
- if (pkg.kind === "member") {
522
- module = { name: cap, from: { kind: "member", repoKey: pkg.repoKey, commit: pkg.commit }, manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: false, dir: pkg.capDir };
523
- } else {
524
- const entry = lock?.packages?.[pkg.package];
525
- if (!entry) throw err("E_PACKAGE_MISSING", `capability agent ${agent.name}: package ${pkg.package} is not in ${LOCK_FILE}`, { capability: cap, package: pkg.package });
526
- const ref = packageRef(pkg.package, entry, catalog, remote);
527
- module = {
528
- name: cap, from: { kind: "package", package: pkg.package, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
529
- manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, dir: pkg.capDir,
530
- };
531
- }
532
- const layers = [{ payload: manifestDefaultsPayload(manifest), origin: { kind: "manifest-default", at: "oats.json#/settings" } }];
533
- if (module.layer === "messaging" && view?.workspace?.messaging && typeof view.workspace.messaging === "object") {
534
- const { byTeam, ...base } = view.workspace.messaging;
535
- layers.push({ payload: base, origin: { kind: "workspace", at: "oats-workspace.yaml#/messaging" } });
536
- }
537
- const host = local.settings?.[cap];
538
- if (host && typeof host === "object") layers.push({ payload: host, origin: { kind: "host", at: `oats-local.yaml#/settings/${cap}` } });
539
- payload = mergePayload(...layers.map((l) => l.payload));
540
- origins = payloadOrigins(layers);
541
- } else {
542
- throw new TypeError("prepareCapabilityAgent: the agent names no source home, and no package entry was given");
543
- }
544
- // The capability's own kernel range, as resolveSoul checks every soul's modules.
545
- const compat = kernelCompatibility({ ...module.manifest, capability: cap });
546
- if (!compat.ok) throw err("E_CAPABILITY_INCOMPATIBLE", `capability agent ${agent.name}: ${cap} requires oats ${compat.range}; this kernel is ${compat.kernel}`, { capability: cap, range: compat.range, kernel: compat.kernel, from: module.from });
547
- for (const { key, value, values } of settingValueProblems(module.manifest, payload)) {
548
- throw err("E_WORKSPACE_SCHEMA", `${cap}: setting ${JSON.stringify(key)} is ${JSON.stringify(value)}, not one of ${values.map((v) => JSON.stringify(v)).join(", ")}`, { capability: cap, key, value, values, reason: "setting-value" });
549
- }
550
- const slots = { knowledge: null, messaging: null, tasks: null };
551
- if (module.layer) slots[module.layer] = cap;
552
- if (module.layer === "knowledge") module.inject = false;
553
- const injects = module.inject !== false && typeof module.manifest.inject === "string" && module.manifest.inject ? [{ module: cap, path: module.manifest.inject }] : [];
554
- const repoKey = module.from.repoKey ?? `package:${module.from.package}`;
555
- const soulEntry = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null, capability: cap };
556
- const soul = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null };
557
- const decl = { resolutionApi: RESOLUTION_API, soul, modules: [module], slots, skills: [], injects };
558
- const payloads = { [cap]: payload };
559
- const declRevision = revisionOf(decl), payloadRevision = revisionOf(payloads);
560
- const resolution = { ...decl, payloads, payloadOrigins: { [cap]: origins }, teams: [], declRevision, payloadRevision, revision: revisionOf({ declRevision, payloadRevision }) };
561
- return { local, deployment, lock: anchor ? null : lock, discovery: view, soulEntry, resolution, remoteOptions, spawn: { providers: {} }, capabilityAgent: true, ...(localModules ? { localModules } : {}) };
562
- }
563
519
 
564
520
  /** Turn a Resolution's modules (already materialized under `home`) into the
565
521
  * capability rows the kernel's hooks/environment/requirements code consumes.
@@ -578,7 +534,8 @@ export function toCapabilityRows(resolution, home) {
578
534
  level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
579
535
  provenance: [m.from.kind === "package" ? `package ${m.from.package} v${m.from.version}` : `member ${m.from.repoKey} @ ${String(m.from.commit).slice(0, 12)}`],
580
536
  settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
581
- skills, inject: m.inject !== false && inject && existsSync(inject) ? inject : undefined,
537
+ settingsOrigins: { ...(resolution.payloadOrigins?.[m.name] && typeof resolution.payloadOrigins[m.name] === "object" ? resolution.payloadOrigins[m.name] : {}) },
538
+ skills, inject: inject && existsSync(inject) ? inject : undefined,
582
539
  skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
583
540
  // Member capabilities are trusted by membership (decision 2); package
584
541
  // capabilities by their declaration in the workspace's packages: (human
@@ -637,67 +594,3 @@ export const INSTANCE_SKILLS_DIR = SKILLS_DIR;
637
594
  export const INSTANCE_MODULES_DIR = MODULES_DIR;
638
595
 
639
596
 
640
- /**
641
- * Workspace model: a capability-defined agent (a package capability's `agents:`
642
- * soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
643
- * not from any instance: every locked package's capability manifests are read
644
- * over the remote; the first `agents/<name>` match has its capability tree fetched
645
- * into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
646
- * (a per-commit cache shared by every such spawn) so the classic capability-agent
647
- * machinery can read it exactly as it reads a materialized instance module.
648
- * → { capability, dir, commit, package, version, manifest, rel } | undefined.
649
- */
650
- export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null, discovery = undefined } = {}) {
651
- if (typeof name !== "string" || !name) return undefined;
652
- const found = loadLocal(contextDir);
653
- const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
654
- const remote = defaultRemote;
655
- const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
656
- // A confirmed member's own (non-private) capability first: membership is its trust
657
- // decision, exactly as for a soul's `from: <member>` module.
658
- for (const row of view?.members || []) {
659
- if (!row?.confirmed && view?.standalone !== true) continue;
660
- for (const cap of row.capabilities || []) {
661
- if (cap.private) continue;
662
- const agents = Array.isArray(cap.manifest?.agents) ? cap.manifest.agents : [];
663
- const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
664
- if (!rel) continue;
665
- const commit = cap.commit ?? row.commit;
666
- const store = join(deployment, MODULES_DIR);
667
- const dir = join(store, `${cap.name}@${String(commit).slice(0, 12)}`);
668
- if (!existsSync(join(dir, "oats.json"))) {
669
- mkdirSync(store, { recursive: true });
670
- await fetchRemoteTree(memberRef(view, remote, cap.repoKey), commit, cap.path, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
671
- }
672
- return { kind: "member", capability: cap.name, dir, capDir: cap.path, commit, repoKey: cap.repoKey, manifest: cap.manifest, rel };
673
- }
674
- }
675
- if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
676
- const lock = readLock(deployment);
677
- // Declaring a package in packages: is the trust decision: a workspace view
678
- // admits only locked packages the workspace still declares (a stale lock entry
679
- // is never a capability-agent source). A standalone view's lock holds only what
680
- // a standalone sync wrote.
681
- const declared = view?.standalone === true ? null : (view?.workspace?.packages && typeof view.workspace.packages === "object" ? view.workspace.packages : {});
682
- for (const [id, entry] of Object.entries(lock.packages || {})) {
683
- if (!entry || typeof entry !== "object") continue;
684
- if (declared && !Object.hasOwn(declared, id)) continue;
685
- let ref;
686
- try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
687
- let read;
688
- try { read = await readPackageManifests(remote, ref, entry.commit, entry.path, { package: id }); } catch { continue; }
689
- for (const cap of read.capabilities) {
690
- const agents = Array.isArray(cap.manifest.agents) ? cap.manifest.agents : [];
691
- const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
692
- if (!rel) continue;
693
- const store = join(deployment, MODULES_DIR);
694
- const dir = join(store, `${cap.name}@${String(entry.commit).slice(0, 12)}`);
695
- if (!existsSync(join(dir, "oats.json"))) {
696
- mkdirSync(store, { recursive: true });
697
- await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
698
- }
699
- return { kind: "package", capability: cap.name, dir, capDir: cap.dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
700
- }
701
- }
702
- return undefined;
703
- }
@@ -240,11 +240,8 @@ function moduleSkills(resolution, module, moduleDir) {
240
240
  return rows;
241
241
  }
242
242
 
243
- /** The inject files a module contributes → [relative path]. resolution.injects rows first, else manifest.inject;
244
- * none for a module row marked `inject: false` (a capability agent's knowledge-layer provider carries no
245
- * memory protocol: lib/instance-resolution.mjs#prepareCapabilityAgent). */
243
+ /** The inject files a module contributes → [relative path]. resolution.injects rows first, else manifest.inject. */
246
244
  function moduleInjects(resolution, module, moduleDir) {
247
- if (module.inject === false) return [];
248
245
  const declared = (resolution.injects || []).filter((i) => i && i.module === module.name);
249
246
  const rels = declared.length
250
247
  ? declared.map((i) => modulePath(i.path, module.name, "inject"))
@@ -566,9 +563,10 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
566
563
  rows.push({ module: name, from: clone(from), recorded, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" });
567
564
  continue;
568
565
  }
569
- const present = Array.isArray(member.capabilities) && member.capabilities.some((c) => c && c.name === name);
570
- if (!present) { rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit }, status: "missing", reason: "capability-absent" }); continue; }
571
- rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit }, status: member.commit === commit ? "current" : "moved" });
566
+ const cap = Array.isArray(member.capabilities) ? member.capabilities.find((c) => c && c.name === name) : null;
567
+ if (!cap) { rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit }, status: "missing", reason: "capability-absent" }); continue; }
568
+ // `version` (feature desktop-facts): the member capability's manifest version now, beside the commit.
569
+ rows.push({ module: name, from: clone(from), recorded, current: { commit: member.commit, version: typeof cap.manifest?.version === "string" ? cap.manifest.version : null }, status: member.commit === commit ? "current" : "moved" });
572
570
  }
573
571
  return rows;
574
572
  }
@@ -584,12 +582,25 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
584
582
  * (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
585
583
  * by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
586
584
  * not reported missing. Returns null for a home without a recorded workspace soul (classic).
585
+ * A PACKAGE soul (`workspace.soul.package`) is compared with the discovery's package souls: the
586
+ * pin moved → "moved"; the package no longer locked/declared → "missing" ("package-absent"); the
587
+ * soul no longer shipped by the package → "missing" ("soul-absent").
587
588
  */
588
589
  export function soulDriftOf(instanceJson, discovery) {
589
590
  const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
590
591
  if (!soul || typeof soul.repoKey !== "string") return null;
591
592
  const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
592
593
  const commit = typeof soul.commit === "string" ? soul.commit : null;
594
+ if (plainObject(soul.package) && typeof soul.package.id === "string") {
595
+ // A package soul: "moved" means the package pin moved (another version/commit is locked now).
596
+ const pkg = { package: soul.package.id, version: soul.package.version ?? null };
597
+ const soulName = typeof soul.name === "string" ? soul.name : name;
598
+ const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
599
+ const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
600
+ const now = listed.find((s) => s.name === soulName) || null;
601
+ if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
602
+ return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
603
+ }
593
604
  const members = Array.isArray(discovery?.members) ? discovery.members : [];
594
605
  const member = members.find((m) => m && m.key === soul.repoKey) || null;
595
606
  const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
@@ -6,8 +6,7 @@
6
6
  * `prepareInstance(dir, soul)` → the soul's Resolution — find the module whose
7
7
  * manifest `command` is the namespace, fetch that capability into the
8
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
9
+ * and dispatch to that
11
10
  * copy with the soul's merged payload as `OATS_SETTINGS`.
12
11
  *
13
12
  * Never "the newest instance's copy" (an instance is not an authority for the