@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
package/lib/packages.mjs CHANGED
@@ -28,7 +28,8 @@
28
28
  * version: "<version string, no leading v>",
29
29
  * commit: "<full 40-hex OID>",
30
30
  * integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
31
- * capabilities: ["<cap name>", …] // sorted
31
+ * capabilities: ["<cap name>", …], // sorted
32
+ * souls: [{ name, path, digest }] // package souls (0.28.0), sorted by name; omitted when none
32
33
  * }
33
34
  * }
34
35
  * }
@@ -57,6 +58,11 @@ export const DEFAULT_PACKAGE_PATH = "oats-package";
57
58
 
58
59
  const OID_RE = /^[0-9a-f]{40}$/;
59
60
  const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
61
+ /** A soul name (docs/soul.schema.json#/$defs/slug): a package soul's name is its directory's. */
62
+ const SOUL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
63
+ /** The one symlink a soul source may carry: its CLAUDE.md → AGENTS.md alias (at the soul's root).
64
+ * Sync and spawn fetch a package soul with this same predicate, so their digests agree. */
65
+ export const SOUL_ALIAS_SYMLINK = (p) => p === "CLAUDE.md";
60
66
  const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
61
67
 
62
68
  /** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
@@ -143,6 +149,14 @@ export function validateLock(lock, { file } = {}) {
143
149
  if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
144
150
  if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
145
151
  if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
152
+ if (entry.souls !== undefined) {
153
+ if (!Array.isArray(entry.souls)) throw bad("souls", "must be an array of { name, path, digest }");
154
+ for (const [i, soul] of entry.souls.entries()) {
155
+ if (!plainObject(soul) || typeof soul.name !== "string" || !SOUL_NAME_RE.test(soul.name)) throw bad(`souls/${i}/name`, "must be a soul name");
156
+ if (typeof soul.path !== "string" || !soul.path || soul.path.split("/").some((p) => p === ".." || p === "") || soul.path.startsWith("/")) throw bad(`souls/${i}/path`, "must be a relative path inside the package");
157
+ if (typeof soul.digest !== "string" || !DIGEST_RE.test(soul.digest)) throw bad(`souls/${i}/digest`, "must be sha256-<hex>");
158
+ }
159
+ }
146
160
  }
147
161
  return lock;
148
162
  }
@@ -168,6 +182,7 @@ export function canonicalLock(lock) {
168
182
  packages[id] = {
169
183
  source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
170
184
  capabilities: [...e.capabilities].sort(),
185
+ ...(Array.isArray(e.souls) && e.souls.length ? { souls: [...e.souls].sort((a, b) => byCodepoint(a.name, b.name)).map((x) => ({ name: x.name, path: x.path, digest: x.digest })) } : {}),
171
186
  };
172
187
  }
173
188
  return { lockfileVersion: LOCK_VERSION, packages };
@@ -319,7 +334,63 @@ export async function readPackageManifests(remote, remoteRef, commit, path, deta
319
334
  capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
320
335
  }
321
336
  capabilities.sort((a, b) => byCodepoint(a.name, b.name));
322
- return { manifest, capabilities };
337
+ // Package souls (0.28.0): ordinary soul directories, versioned and locked with the package. A soul's
338
+ // name is its directory's (as for a member's souls/<name>); soul.yaml is validated where it is
339
+ // discovered, AGENTS.md is required where it is locked (packageSoulDigests).
340
+ const souls = [];
341
+ if (manifest.souls !== undefined) {
342
+ if (!Array.isArray(manifest.souls)) throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath}: "souls" must be a list of soul directories`, { ...details, path: manifestPath });
343
+ const seenSouls = new Map();
344
+ for (const rel of manifest.souls) {
345
+ assertRelPath(`${manifestPath} souls[]`, rel, { ...details, path: manifestPath });
346
+ const soulPath = posix.normalize(rel).replace(/\/+$/, "");
347
+ const name = posix.basename(soulPath);
348
+ if (!SOUL_NAME_RE.test(name)) throw oatsError("E_PACKAGE_MANIFEST", `${manifestPath} souls[]: ${JSON.stringify(rel)} — a soul's directory is its name, which must match ${SOUL_NAME_RE}`, { ...details, path: manifestPath, soul: rel });
349
+ if (seenSouls.has(name)) throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares soul ${JSON.stringify(name)} twice (${seenSouls.get(name)} and ${soulPath})`, { ...details, path: manifestPath, duplicate: name, other: seenSouls.get(name) });
350
+ seenSouls.set(name, soulPath);
351
+ souls.push({ name, path: soulPath, dir: pjoin(path, soulPath) });
352
+ }
353
+ souls.sort((a, b) => byCodepoint(a.name, b.name));
354
+ }
355
+ return { manifest, capabilities, souls };
356
+ }
357
+
358
+ /** Fetch each package soul at `commit` and return its lock record `{ name, path, digest }` — the digest
359
+ * is what `fetchRemoteTree` reports with SOUL_ALIAS_SYMLINK, the same fetch a spawn makes and verifies.
360
+ * A soul without soul.yaml or AGENTS.md is E_PACKAGE_MANIFEST. */
361
+ export async function packageSoulDigests(remote, remoteRef, commit, souls, details = {}) {
362
+ if (!souls.length) return [];
363
+ if (typeof remote.fetchRemoteTree !== "function") throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package soul's digest", { path: "/remote" });
364
+ const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-soul-"));
365
+ try {
366
+ const out = [];
367
+ for (const soul of souls) {
368
+ const dest = join(scratch, soul.name);
369
+ let digest;
370
+ try { ({ digest } = await remote.fetchRemoteTree(remoteRef, commit, soul.dir, dest, { allowSymlinks: SOUL_ALIAS_SYMLINK })); }
371
+ catch (e) {
372
+ if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} is missing at ${String(commit).slice(0, 12)}`, { ...details, soul: soul.name, path: soul.dir });
373
+ throw e;
374
+ }
375
+ // A fake remote reports a digest without copying: only a real copy can be checked for its files.
376
+ if (existsSync(dest)) {
377
+ for (const file of ["soul.yaml", "AGENTS.md"]) {
378
+ if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
379
+ }
380
+ }
381
+ out.push({ name: soul.name, path: soul.path, digest: assertDigest(`package soul ${soul.path} digest`, digest, details) });
382
+ }
383
+ return out;
384
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
385
+ }
386
+
387
+ /** The repo ref a lock entry is read from without a catalog: its recorded url, else the key of a
388
+ * `git:<key>@<ref>` source. → string | null (a catalog entry written without a url). */
389
+ export function lockedPackageRef(entry) {
390
+ if (typeof entry?.url === "string" && entry.url) return entry.url;
391
+ const m = /^git:(.+)@([^@]+)$/.exec(String(entry?.source ?? ""));
392
+ if (!m) return null;
393
+ return m[1].startsWith("local/") ? m[1].slice("local/".length) : `git:${m[1]}`;
323
394
  }
324
395
 
325
396
  /** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
@@ -345,6 +416,23 @@ function assertDigest(what, value, details) {
345
416
  }
346
417
 
347
418
  /** Bind `remoteOptions` (cacheDir, exec, …) into every call of a contract-§1 remote. */
419
+ /** A remote whose reads at a commit are shared for one command (feature desktop-facts): the souls and
420
+ * capabilities listings resolve many souls over the same package manifests and skill listings, and a read at
421
+ * an immutable commit gives the same answer every time. A failed read is not kept (it is retried). */
422
+ export function memoizedRemote(remote) {
423
+ const memo = { ...remote };
424
+ for (const name of ["readRemoteFile", "listRemoteTree"]) {
425
+ if (typeof remote[name] !== "function") continue;
426
+ const cache = new Map();
427
+ memo[name] = (...args) => {
428
+ const key = JSON.stringify(args);
429
+ if (!cache.has(key)) cache.set(key, Promise.resolve(remote[name](...args)).catch((e) => { cache.delete(key); throw e; }));
430
+ return cache.get(key);
431
+ };
432
+ }
433
+ return memo;
434
+ }
435
+
348
436
  export function bindRemote(remote, remoteOptions) {
349
437
  if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
350
438
  const bound = { ...remote };
@@ -419,23 +507,36 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
419
507
  // The lock's capability list must still be what the package declares at that commit —
420
508
  // spawn refuses a mismatch (E_PACKAGE_INTEGRITY why:capabilities), so sync must too, or
421
509
  // a bad list would pass every sync and fail every spawn.
422
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
510
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
423
511
  const listed = capabilities.map((c) => c.name).sort(), locked = [...old.capabilities].sort();
424
512
  if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
425
513
  throw oatsError("E_PACKAGE_INTEGRITY",
426
514
  `packages.${id} ${version} @ ${obs.commit}: the lock lists capabilities [${locked.join(", ")}] but the package declares [${listed.join(", ")}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
427
515
  { ...details, version, why: "capabilities", listed, locked });
428
516
  }
429
- const { approved: _dropped, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
430
- nextPackages[id] = kept;
517
+ // The lock's souls must be what the package ships at that commit: the same names, paths and
518
+ // digests. A lock written before 0.28.0 carries no `souls` — it is filled in, not refused.
519
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
520
+ if (old.souls !== undefined) {
521
+ const show = (list) => list.map((x) => `${x.name}@${x.path}:${x.digest.slice(7, 19)}`).sort().join(", ");
522
+ if (show(old.souls) !== show(souls)) {
523
+ throw oatsError("E_PACKAGE_INTEGRITY",
524
+ `packages.${id} ${version} @ ${obs.commit}: the lock's souls [${show(old.souls)}] do not match what the package ships [${show(souls)}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
525
+ { ...details, version, why: "souls", listed: souls, locked: old.souls });
526
+ }
527
+ }
528
+ const { approved: _dropped, souls: _souls, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
529
+ nextPackages[id] = { ...kept, ...(souls.length ? { souls } : {}) };
431
530
  continue;
432
531
  }
433
532
 
434
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
533
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
435
534
  const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
535
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
436
536
  nextPackages[id] = {
437
537
  source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
438
538
  capabilities: capabilities.map((c) => c.name).sort(),
539
+ ...(souls.length ? { souls } : {}),
439
540
  };
440
541
  changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit });
441
542
  }
package/lib/remote.mjs CHANGED
@@ -475,7 +475,7 @@ function assertNoCollisions(entries, ref, commit, prefix) {
475
475
  async function lsTree(repo, spec, { flags = [], path, ref, commit } = {}) {
476
476
  try {
477
477
  const args = ["ls-tree", "-l", "-z", ...flags, spec];
478
- if (path !== undefined) args.push("--", path);
478
+ if (path !== undefined) args.push("--", ...[].concat(path));
479
479
  const out = await repo.local(args, { maxBuffer: 64 * 1024 * 1024 });
480
480
  return parseLsTree(out.stdout);
481
481
  } catch (error) {
@@ -514,6 +514,26 @@ export async function readRemoteFile(refText, commitArg, path, options = {}) {
514
514
  return { bytes: out.stdout, size: out.stdout.length };
515
515
  }
516
516
 
517
+ /** The Git tree object ids of `dirs` at `commit`, in one listing (feature desktop-facts: a member capability's
518
+ * fingerprint — content-addressed, so the same bytes give the same id; NOT the sha256 content digest a
519
+ * materialized module records). → Map dir → oid, null for a dir that is not a directory there. */
520
+ export async function remoteTreeOids(refText, commitArg, dirs, options = {}) {
521
+ const ref = parseRepoRef(refText, options);
522
+ requireCommit(commitArg);
523
+ const rels = dirs.map((dir) => normalizeTreePath(dir, { allowRoot: false }));
524
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
525
+ const entries = rels.length ? (await lsTree(repo, commit, { path: rels, ref, commit })) ?? [] : [];
526
+ return new Map(dirs.map((dir, i) => [dir, entries.find((e) => e.path === rels[i] && e.type === "tree")?.oid ?? null]));
527
+ }
528
+
529
+ /** A browsable URL of a repo at a commit (feature desktop-facts): the file's page with `path`, else the tree.
530
+ * Only GitHub keys have one; any other host or a local repo → null. */
531
+ export function browseUrl(key, commit, path = null) {
532
+ if (typeof key !== "string" || !/^github\.com\/[^/]+\/[^/]+$/.test(key) || typeof commit !== "string" || !commit) return null;
533
+ const clean = typeof path === "string" && path ? path.split("/").filter(Boolean).map(encodeURIComponent).join("/") : null;
534
+ return clean ? `https://${key}/blob/${commit}/${clean}` : `https://${key}/tree/${commit}`;
535
+ }
536
+
517
537
  /**
518
538
  * → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
519
539
  * Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
package/lib/resolve.mjs CHANGED
@@ -368,12 +368,16 @@ export function teamsOf(workspace, labels) {
368
368
  * Two labels that give one capability different entries (`off` vs a location, or two locations) →
369
369
  * E_TEAM_CONFLICT { capability, labels: [a, b] }; identical entries are not a conflict, and a capability
370
370
  * the soul names itself is not one either (the soul's entry wins over both).
371
+ * `offs` (optional, feature desktop-facts): receives what the soul turned off — { name, reason: "off",
372
+ * overrides } for each capability its own `off` removed from a lower layer, and { name, reason: "slot-none",
373
+ * slot, overrides: "workspace" } for a slot default its `<slot>: none` dropped (`overrides`: fromOfVia vocabulary).
371
374
  */
372
- export function composeCapabilities(workspace, soulDefinition, { team = null, labels = team === null ? [] : [team] } = {}) {
375
+ export function composeCapabilities(workspace, soulDefinition, { team = null, labels = team === null ? [] : [team], offs = null } = {}) {
373
376
  const map = new Map();
374
377
  const apply = (entries, via, path) => {
375
378
  for (const [name, value] of Object.entries(entries || {})) {
376
379
  const choice = choiceOf(value, `${path}/${name}`, via);
380
+ if (choice === "off" && via === "soul" && offs && map.has(name)) offs.push({ name, reason: "off", overrides: fromOfVia(map.get(name).via) });
377
381
  if (choice === "off") map.delete(name);
378
382
  else map.set(name, { name, from: choice.from, via });
379
383
  }
@@ -381,6 +385,7 @@ export function composeCapabilities(workspace, soulDefinition, { team = null, la
381
385
  const defaults = isObject(workspace?.defaults) ? workspace.defaults : {};
382
386
  for (const slot of SLOTS) {
383
387
  const d = defaults[slot];
388
+ if (soulDefinition?.[slot] === "none" && offs && isObject(d)) for (const name of Object.keys(d)) offs.push({ name, reason: "slot-none", slot, overrides: "workspace" });
384
389
  if (soulDefinition?.[slot] === "none" || d === "none" || !isObject(d)) continue;
385
390
  const names = Object.keys(d);
386
391
  if (names.length > 1) throw fail("E_WORKSPACE_SCHEMA", `defaults.${slot} names ${names.length} capabilities; a slot default names at most one`, { path: `/defaults/${slot}`, names });
@@ -472,6 +477,15 @@ function lookupPackage(lock, name, via, soul, declared) {
472
477
  return providing;
473
478
  }
474
479
 
480
+ /** `from: here` in a package soul: the capability must be one its OWN locked package provides. */
481
+ function ownPackageEntry(lock, name, via, soul, id) {
482
+ const where = { capability: name, from: "here", soul: soul.name, via, package: id };
483
+ const entry = isObject(lock?.packages) ? lock.packages[id] : null;
484
+ if (!isObject(entry)) throw fail("E_PACKAGE_MISSING", `${name}: from: here in package soul ${id}/${soul.name} needs ${id} in the lock — run \`oats sync\``, { ...where, reason: "no-lock" });
485
+ if (!entry.capabilities.includes(name)) throw fail("E_CAPABILITY_MISSING", `${name}: from: here in package soul ${id}/${soul.name}, but package ${id} v${entry.version} provides [${entry.capabilities.join(", ")}]`, { ...where, version: entry.version, listed: [...entry.capabilities] });
486
+ return { id, entry };
487
+ }
488
+
475
489
  /** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
476
490
  export function packageRef(id, entry, catalog, remote) {
477
491
  const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
@@ -538,6 +552,28 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
538
552
  return skills;
539
553
  }
540
554
 
555
+ /** What a capability provides, by name (feature desktop-facts): its skills (enumerated exactly as a spawn
556
+ * would), commands and hooks. `skills` is null when its declared skills cannot be listed (a spawn of it
557
+ * would refuse; the listing does not). */
558
+ export async function capabilityProvides({ ref, commit, dir, manifest, remote: injected, remoteOptions }) {
559
+ const remote = remoteOf({ remote: injected, remoteOptions });
560
+ const keys = (o) => (isObject(o) ? Object.keys(o).sort(byCodepoint) : []);
561
+ let skills;
562
+ try {
563
+ const missing = (raw, why, text) => fail("E_CAPABILITY_MISSING", text, { skill: raw, why });
564
+ skills = (await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })).map((x) => x.name).sort(byCodepoint);
565
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) skills = null; else throw e; }
566
+ return { skills, commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
567
+ }
568
+
569
+ /** The capability manifests of a locked package, read at its locked commit (feature desktop-facts). */
570
+ export async function lockedPackageCapabilities(id, entry, { catalog = null, remote: injected, remoteOptions } = {}) {
571
+ const remote = remoteOf({ remote: injected, remoteOptions });
572
+ const ref = packageRef(id, entry, catalog, remote);
573
+ const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, { id, version: entry.version, commit: entry.commit });
574
+ return { ref, capabilities };
575
+ }
576
+
541
577
  /* ───────────────────────────── resolveSoul ────────────────────────────── */
542
578
 
543
579
  /**
@@ -568,9 +604,13 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
568
604
  const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, team, path: soulEntry.path ?? null };
569
605
 
570
606
  // Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
607
+ // What the soul turned off (feature desktop-facts): its own `off` over a lower layer, and a `<slot>: none`
608
+ // that dropped a workspace default below. Provenance, like slotsFrom.
609
+ const offs = [];
571
610
  const declared = discovery?.standalone === true
572
- ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { labels })
573
- : composeCapabilities(workspace, definition, { labels });
611
+ ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { labels, offs })
612
+ : composeCapabilities(workspace, definition, { labels, offs });
613
+ const turnedOff = offs;
574
614
 
575
615
  const modules = [];
576
616
  const skills = [];
@@ -582,8 +622,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
582
622
  const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
583
623
  for (const { name, from, via } of declared) {
584
624
  let module;
585
- if (from === "package") {
586
- const { id, entry } = lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
625
+ const ownPackage = from === "here" && typeof soulEntry.package === "string";
626
+ if (from === "package" || ownPackage) {
627
+ // `from: here` in a package soul is its own package, at the locked commit (§2.2).
628
+ const { id, entry } = ownPackage
629
+ ? ownPackageEntry(lock, name, via, soul, soulEntry.package)
630
+ : lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
587
631
  const ref = packageRef(id, entry, catalog, remote);
588
632
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
589
633
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
@@ -595,7 +639,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
595
639
  const cap = capabilities.find((c) => c.name === name);
596
640
  if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
597
641
  const layer = layerOf(cap.manifest);
598
- if (layer && emptied.has(layer) && via !== "soul") continue;
642
+ if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
599
643
  module = {
600
644
  name, from: { kind: "package", package: id, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
601
645
  manifest: clone(cap.manifest), layer, private: cap.manifest.private === true, dir: cap.dir,
@@ -605,7 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
605
649
  } else {
606
650
  const { row, cap } = lookupMember(discovery, soul, name, from, via, lock);
607
651
  const layer = layerOf(cap.manifest);
608
- if (layer && emptied.has(layer) && via !== "soul") continue;
652
+ if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
609
653
  const ref = memberRef(discovery, remote, cap.repoKey);
610
654
  module = {
611
655
  name, from: { kind: "member", repoKey: cap.repoKey, commit: cap.commit ?? row.commit },
@@ -724,6 +768,16 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
724
768
  throw fail("E_CAPABILITY_INCOMPATIBLE", `${m.name} requires oats ${c.range}; this kernel is ${kernel} — ${remedy}`, { capability: m.name, range: c.range, kernel, from: m.from });
725
769
  }
726
770
 
771
+ // Capability-defined agents (`agents:` in a manifest) were removed in 0.29.0: an agent ships as a
772
+ // soul. A module still declaring them is refused before anything is composed from it.
773
+ for (const m of modules) {
774
+ if (m.manifest?.agents === undefined) continue;
775
+ const agents = Array.isArray(m.manifest.agents) ? m.manifest.agents.filter((a) => typeof a === "string") : [];
776
+ const how = "ship each agent as a soul — a package soul (`souls/<name>/` beside the package's capabilities, spawned as `oats spawn <package>/<name>`) or a member soul (`souls/<name>/` in a member) — and drop `agents:` from the manifest";
777
+ const remedy = m.from.kind === "package" ? `pin a release of ${m.from.package} without \`agents:\`, or ${how}` : `in ${m.from.repoKey}: ${how}`;
778
+ throw fail("E_CAPABILITY_AGENTS_REMOVED", `${m.name} declares \`agents:\` (${agents.join(", ") || "…"}); capability-defined agents were removed in OATS 0.29.0 — ${remedy}`, { capability: m.name, agents, from: m.from });
779
+ }
780
+
727
781
  // Compatibility floors (soul.compatibility) are constraints on PACKAGE versions.
728
782
  for (const [cap, range] of Object.entries(isObject(definition.compatibility) ? definition.compatibility : {})) {
729
783
  const m = modules.find((x) => x.name === cap);
@@ -755,9 +809,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
755
809
  // decision 3) is live messaging state, not composition: it stays out of both fingerprints too, so a
756
810
  // single-label soul's revision is what it was.
757
811
  // `slotsFrom` (where each filled slot's capability came from) is provenance, like payloadOrigins: outside
758
- // both fingerprints, so the same capability reached another way is not a composition change.
812
+ // both fingerprints, so the same capability reached another way is not a composition change. So are
813
+ // `capabilitiesFrom` (the same per composed capability) and `turnedOff` (feature desktop-facts).
759
814
  const teams = teamsOf(discovery?.standalone === true ? null : workspace, labels);
760
- return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, slotsFrom, declRevision, payloadRevision, revision });
815
+ const capabilitiesFrom = Object.fromEntries(modules.map((m) => [m.name, fromOfVia(viaOf.get(m.name))]));
816
+ turnedOff.sort((a, b) => byCodepoint(a.name, b.name));
817
+ return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, slotsFrom, capabilitiesFrom, turnedOff, declRevision, payloadRevision, revision });
761
818
  }
762
819
 
763
820
  /** Where a composed capability came from, as the Desktop names it (`layers.<layer>.from`): the soul's own
@@ -783,6 +840,11 @@ function assertSoulDiscovered(discovery, soulEntry) {
783
840
  const { name, repoKey } = soulEntry;
784
841
  const where = { soul: name, repoKey };
785
842
  const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
843
+ if (typeof soulEntry.package === "string") {
844
+ // A package soul: the discovery listed it from the lock, at this package's locked commit.
845
+ if ((discovery?.packageSouls || []).some((s) => sameSoul(s) && s.package === soulEntry.package)) return;
846
+ throw fail("E_PACKAGE_MISSING", `soul ${soulEntry.package}/${name}: the discovery does not list it at ${short(soulEntry.commit)} — the soul entry is stale; run \`oats sync\` and discover again`, { ...where, package: soulEntry.package, reason: "stale" });
847
+ }
786
848
  if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
787
849
  const row = memberRow(discovery, repoKey);
788
850
  if (!row) throw fail("E_NOT_A_MEMBER", `soul ${name}: ${repoKey} is not a member of the workspace${discovery?.key ? ` ${discovery.key}` : ""} (nor an external soul) — a soul is spawned from a confirmed member or an external entry`, { ...where, reason: "not-listed" });