@awebai/oats 0.28.0 → 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 (68) hide show
  1. package/bin/oats.mjs +296 -106
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
  3. package/capabilities/oats-okf/injects/okf.md +33 -33
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +2 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +1 -5
  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/okf-validate.mjs +123 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +28 -3
  11. package/capabilities/oats-okf/lib/stores.mjs +9 -4
  12. package/capabilities/oats-okf/lib/worker.mjs +82 -8
  13. package/capabilities/oats-okf/oats.json +14 -8
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
  16. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  17. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  18. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  19. package/capabilities/oats-okf-harvest/oats.json +26 -0
  20. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  21. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  22. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
  23. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  24. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  25. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  26. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  27. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  28. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  29. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  30. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  32. package/capabilities/oats-review/injects/review.md +3 -2
  33. package/capabilities/oats-review/oats.json +3 -4
  34. package/docs/capabilities.md +41 -9
  35. package/docs/capability-manifest.schema.json +0 -7
  36. package/docs/desktop-cli-api.md +257 -11
  37. package/docs/implementation.md +1 -1
  38. package/docs/knowledge-capability-authoring.md +8 -2
  39. package/docs/knowledge-reference/package-craft.md +8 -5
  40. package/docs/knowledge.md +101 -0
  41. package/docs/oats-local.schema.json +31 -1
  42. package/docs/official-catalog.md +7 -4
  43. package/docs/packages.md +11 -5
  44. package/docs/release-lane.md +1 -1
  45. package/docs/release-notes/v0.29.0.md +240 -0
  46. package/docs/schedules.md +133 -5
  47. package/docs/souls-and-instances.md +4 -6
  48. package/docs/workspaces.md +11 -2
  49. package/lib/automations.mjs +369 -0
  50. package/lib/core.mjs +65 -154
  51. package/lib/instance-inspect.mjs +12 -4
  52. package/lib/instance-resolution.mjs +31 -182
  53. package/lib/materialize.mjs +5 -7
  54. package/lib/operator-dispatch.mjs +1 -2
  55. package/lib/packages.mjs +17 -0
  56. package/lib/remote.mjs +21 -1
  57. package/lib/resolve.mjs +51 -7
  58. package/lib/schedule.mjs +211 -41
  59. package/lib/triggers.mjs +182 -49
  60. package/lib/workspace.mjs +1 -1
  61. package/package-catalog.json +6 -4
  62. package/package.json +1 -1
  63. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  64. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  65. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  66. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  67. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  68. /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, SOUL_ALIAS_SYMLINK } 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
 
@@ -143,7 +143,7 @@ export function disabledEntry(local, soulEntry) {
143
143
  export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
144
144
  const recorded = (reason, error) => ({ teams: Array.isArray(meta?.teams) ? meta.teams : null, source: "recorded", reason, ...(error ? { error } : {}) });
145
145
  const soul = meta?.workspace?.soul;
146
- // 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.
147
147
  if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
148
148
  // A package soul's labels are pinned with the package: the spawn-time record is its answer.
149
149
  if (soul.package && typeof soul.package === "object") return recorded("package-soul");
@@ -380,18 +380,25 @@ export function resolveMemberClone(prepared, { explicit } = {}) {
380
380
  if (typeof explicit === "string" && explicit.trim()) return isAbsolute(explicit) ? resolvePath(explicit) : resolvePath(deployment, explicit);
381
381
  const key = prepared?.soulEntry?.repoKey;
382
382
  if (typeof key !== "string" || !key) return null;
383
- 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;
384
391
  if (clones && typeof clones === "object") {
385
392
  for (const [written, value] of Object.entries(clones)) {
386
393
  if (!sameRepoKey(canonicalCloneKey(written), key)) continue;
387
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:" });
388
395
  const path = isAbsolute(value) ? resolvePath(value) : resolvePath(deployment, value);
389
- return verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` });
396
+ return { path: verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` }), rule: "clones" };
390
397
  }
391
398
  }
392
399
  const convention = conventionCloneDir(deployment, key);
393
- if (!existsSync(convention)) return null;
394
- 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" };
395
402
  }
396
403
 
397
404
  /** The clone url of the soul's member as the workspace names it (for remedies). */
@@ -417,6 +424,17 @@ export function requireMemberClone(prepared, { explicit } = {}) {
417
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>" } });
418
425
  }
419
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
+
420
438
  /** The async half of a spawn: everything that touches the network. Returns a
421
439
  * PREPARED object that `spawnInstance` consumes synchronously. */
422
440
  export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
@@ -495,115 +513,9 @@ export async function discoverOrStandalone(local, { lock, deployment, remoteOpti
495
513
  /** Materialize a prepared resolution into `home`. Called by spawnInstance after
496
514
  * the home directory exists and before the harness is launched. */
497
515
  export async function materializePrepared(prepared, home) {
498
- const localModules = prepared.localModules || null;
499
- // A capability agent's module copied from its anchor's verified copy (prepareCapabilityAgent):
500
- // materialize still checks the copy's digest against the one the anchor recorded.
501
- const fetch = localModules ? async (ref, commit, dir, dest, options) => {
502
- const src = localModules[basename(dest)];
503
- if (!src) return defaultRemote.fetchRemoteTree(ref, commit, dir, dest, options);
504
- copyLocalModule(src, dest);
505
- return { digest: defaultRemote.contentDigest(dest, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK }) };
506
- } : undefined;
507
- return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir, ...(fetch ? { fetch } : {}) });
508
- }
509
- /** Copy a materialized module tree (regular files, directories, the CLAUDE.md alias) — nothing else. */
510
- function copyLocalModule(src, dest, rel = "") {
511
- mkdirSync(dest, { recursive: true });
512
- for (const name of readdirSync(src).sort()) {
513
- const from = join(src, name), to = join(dest, name), at = rel ? `${rel}/${name}` : name, st = lstatSync(from);
514
- if (st.isSymbolicLink()) {
515
- if (!defaultRemote.OATS_ALIAS_SYMLINK(at) || readlinkSync(from) !== "AGENTS.md") throw err("E_REMOTE_TREE_UNSAFE", `${from} is a symlink`, { path: from, why: "symlink" });
516
- symlinkSync("AGENTS.md", to);
517
- } else if (st.isDirectory()) copyLocalModule(from, to, at);
518
- else if (st.isFile()) { copyFileSync(from, to); chmodSync(to, (st.mode & 0o111) ? 0o755 : 0o644); }
519
- else throw err("E_REMOTE_TREE_UNSAFE", `${from} is not a regular file`, { path: from, why: "device" });
520
- }
516
+ return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir });
521
517
  }
522
518
 
523
- /**
524
- * The prepared resolution of a CAPABILITY AGENT (a capability's `agents:` soul, e.g.
525
- * OKF's memory-harvest worker) — lead decision c3 Q1. It composes its providing
526
- * capability's module and NOTHING else: no workspace defaults, no knowledge,
527
- * messaging or tasks module, so no provider hook of any slot runs for it, and the
528
- * providing module's own hooks do not run either (a manifest has no way to declare
529
- * them for an agent). A knowledge-layer provider's inject is left out: a service
530
- * agent carries no memory protocol.
531
- *
532
- * The module comes from `agent._manifestSource` — the instance home whose module
533
- * copy declared the agent (the --parent/--relative-to anchor first): its recorded
534
- * `modules[<cap>]` (copied from that home's verified copy, pinned to the recorded
535
- * digest) and its recorded `providers[<cap>]` payload. Without such a home (the
536
- * source instance is gone), `pkg` — the deployment lock's package entry that
537
- * declares the agent (resolvePackageCapabilityAgent) — supplies the module, and the
538
- * payload is the manifest defaults ⊕ the workspace's messaging payload (for a
539
- * messaging-layer provider) ⊕ oats-local.yaml `settings.<cap>`.
540
- */
541
- export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg = null, remoteOptions, catalog = null, remote = defaultRemote } = {}) {
542
- const found = loadLocal(contextDir);
543
- const local = found.local;
544
- const deployment = dirname(found.path);
545
- const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
546
- const view = discovery ?? await discoverOrStandalone(local, { lock, remoteOptions });
547
- const cap = agent.capability;
548
- let module, payload, origins, localModules = null;
549
- const anchor = pkg ? null : agent._manifestSource;
550
- if (anchor) {
551
- let meta;
552
- try { meta = JSON.parse(readFileSync(join(anchor, "instance.json"), "utf8")); } catch { meta = null; }
553
- const recorded = meta?.modules?.[cap];
554
- const moduleDir = join(anchor, MODULES_DIR, cap);
555
- if (!recorded?.from || typeof recorded.digest !== "string" || !existsSync(join(moduleDir, "oats.json"))) {
556
- 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 });
557
- }
558
- const manifest = JSON.parse(readFileSync(join(moduleDir, "oats.json"), "utf8"));
559
- module = { name: cap, from: { ...recorded.from }, manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, digest: recorded.digest };
560
- payload = meta.providers?.[cap] && typeof meta.providers[cap] === "object" ? JSON.parse(JSON.stringify(meta.providers[cap])) : {};
561
- origins = Object.fromEntries(Object.keys(payload).map((k) => [`/${k.replace(/~/g, "~0").replace(/\//g, "~1")}`, { kind: "anchor", at: `${join(anchor, "instance.json")}#/providers/${cap}` }]));
562
- localModules = { [cap]: moduleDir };
563
- } else if (pkg) {
564
- const manifest = pkg.manifest;
565
- if (pkg.kind === "member") {
566
- 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 };
567
- } else {
568
- const entry = lock?.packages?.[pkg.package];
569
- if (!entry) throw err("E_PACKAGE_MISSING", `capability agent ${agent.name}: package ${pkg.package} is not in ${LOCK_FILE}`, { capability: cap, package: pkg.package });
570
- const ref = packageRef(pkg.package, entry, catalog, remote);
571
- module = {
572
- name: cap, from: { kind: "package", package: pkg.package, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
573
- manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, dir: pkg.capDir,
574
- };
575
- }
576
- const layers = [{ payload: manifestDefaultsPayload(manifest), origin: { kind: "manifest-default", at: "oats.json#/settings" } }];
577
- if (module.layer === "messaging" && view?.workspace?.messaging && typeof view.workspace.messaging === "object") {
578
- const { byTeam, ...base } = view.workspace.messaging;
579
- layers.push({ payload: base, origin: { kind: "workspace", at: "oats-workspace.yaml#/messaging" } });
580
- }
581
- const host = local.settings?.[cap];
582
- if (host && typeof host === "object") layers.push({ payload: host, origin: { kind: "host", at: `oats-local.yaml#/settings/${cap}` } });
583
- payload = mergePayload(...layers.map((l) => l.payload));
584
- origins = payloadOrigins(layers);
585
- } else {
586
- throw new TypeError("prepareCapabilityAgent: the agent names no source home, and no package entry was given");
587
- }
588
- // The capability's own kernel range, as resolveSoul checks every soul's modules.
589
- const compat = kernelCompatibility({ ...module.manifest, capability: cap });
590
- 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 });
591
- for (const { key, value, values } of settingValueProblems(module.manifest, payload)) {
592
- 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" });
593
- }
594
- const slots = { knowledge: null, messaging: null, tasks: null };
595
- if (module.layer) slots[module.layer] = cap;
596
- if (module.layer === "knowledge") module.inject = false;
597
- const injects = module.inject !== false && typeof module.manifest.inject === "string" && module.manifest.inject ? [{ module: cap, path: module.manifest.inject }] : [];
598
- const repoKey = module.from.repoKey ?? `package:${module.from.package}`;
599
- const soulEntry = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null, capability: cap };
600
- const soul = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null };
601
- const decl = { resolutionApi: RESOLUTION_API, soul, modules: [module], slots, skills: [], injects };
602
- const payloads = { [cap]: payload };
603
- const declRevision = revisionOf(decl), payloadRevision = revisionOf(payloads);
604
- const resolution = { ...decl, payloads, payloadOrigins: { [cap]: origins }, teams: [], declRevision, payloadRevision, revision: revisionOf({ declRevision, payloadRevision }) };
605
- return { local, deployment, lock: anchor ? null : lock, discovery: view, soulEntry, resolution, remoteOptions, spawn: { providers: {} }, capabilityAgent: true, ...(localModules ? { localModules } : {}) };
606
- }
607
519
 
608
520
  /** Turn a Resolution's modules (already materialized under `home`) into the
609
521
  * capability rows the kernel's hooks/environment/requirements code consumes.
@@ -622,7 +534,8 @@ export function toCapabilityRows(resolution, home) {
622
534
  level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
623
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)}`],
624
536
  settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
625
- 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,
626
539
  skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
627
540
  // Member capabilities are trusted by membership (decision 2); package
628
541
  // capabilities by their declaration in the workspace's packages: (human
@@ -681,67 +594,3 @@ export const INSTANCE_SKILLS_DIR = SKILLS_DIR;
681
594
  export const INSTANCE_MODULES_DIR = MODULES_DIR;
682
595
 
683
596
 
684
- /**
685
- * Workspace model: a capability-defined agent (a package capability's `agents:`
686
- * soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
687
- * not from any instance: every locked package's capability manifests are read
688
- * over the remote; the first `agents/<name>` match has its capability tree fetched
689
- * into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
690
- * (a per-commit cache shared by every such spawn) so the classic capability-agent
691
- * machinery can read it exactly as it reads a materialized instance module.
692
- * → { capability, dir, commit, package, version, manifest, rel } | undefined.
693
- */
694
- export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null, discovery = undefined } = {}) {
695
- if (typeof name !== "string" || !name) return undefined;
696
- const found = loadLocal(contextDir);
697
- const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
698
- const remote = defaultRemote;
699
- const view = discovery === undefined ? await discoverOrStandalone(found.local, { deployment, remoteOptions }) : discovery;
700
- // A confirmed member's own (non-private) capability first: membership is its trust
701
- // decision, exactly as for a soul's `from: <member>` module.
702
- for (const row of view?.members || []) {
703
- if (!row?.confirmed && view?.standalone !== true) continue;
704
- for (const cap of row.capabilities || []) {
705
- if (cap.private) continue;
706
- const agents = Array.isArray(cap.manifest?.agents) ? cap.manifest.agents : [];
707
- const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
708
- if (!rel) continue;
709
- const commit = cap.commit ?? row.commit;
710
- const store = join(deployment, MODULES_DIR);
711
- const dir = join(store, `${cap.name}@${String(commit).slice(0, 12)}`);
712
- if (!existsSync(join(dir, "oats.json"))) {
713
- mkdirSync(store, { recursive: true });
714
- await fetchRemoteTree(memberRef(view, remote, cap.repoKey), commit, cap.path, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
715
- }
716
- return { kind: "member", capability: cap.name, dir, capDir: cap.path, commit, repoKey: cap.repoKey, manifest: cap.manifest, rel };
717
- }
718
- }
719
- if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
720
- const lock = readLock(deployment);
721
- // Declaring a package in packages: is the trust decision: a workspace view
722
- // admits only locked packages the workspace still declares (a stale lock entry
723
- // is never a capability-agent source). A standalone view's lock holds only what
724
- // a standalone sync wrote.
725
- const declared = view?.standalone === true ? null : (view?.workspace?.packages && typeof view.workspace.packages === "object" ? view.workspace.packages : {});
726
- for (const [id, entry] of Object.entries(lock.packages || {})) {
727
- if (!entry || typeof entry !== "object") continue;
728
- if (declared && !Object.hasOwn(declared, id)) continue;
729
- let ref;
730
- try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
731
- let read;
732
- try { read = await readPackageManifests(remote, ref, entry.commit, entry.path, { package: id }); } catch { continue; }
733
- for (const cap of read.capabilities) {
734
- const agents = Array.isArray(cap.manifest.agents) ? cap.manifest.agents : [];
735
- const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
736
- if (!rel) continue;
737
- const store = join(deployment, MODULES_DIR);
738
- const dir = join(store, `${cap.name}@${String(entry.commit).slice(0, 12)}`);
739
- if (!existsSync(join(dir, "oats.json"))) {
740
- mkdirSync(store, { recursive: true });
741
- await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
742
- }
743
- return { kind: "package", capability: cap.name, dir, capDir: cap.dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
744
- }
745
- }
746
- return undefined;
747
- }
@@ -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
  }
@@ -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
package/lib/packages.mjs CHANGED
@@ -416,6 +416,23 @@ function assertDigest(what, value, details) {
416
416
  }
417
417
 
418
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
+
419
436
  export function bindRemote(remote, remoteOptions) {
420
437
  if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
421
438
  const bound = { ...remote };
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 });
@@ -547,6 +552,28 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
547
552
  return skills;
548
553
  }
549
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
+
550
577
  /* ───────────────────────────── resolveSoul ────────────────────────────── */
551
578
 
552
579
  /**
@@ -577,9 +604,13 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
577
604
  const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, team, path: soulEntry.path ?? null };
578
605
 
579
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 = [];
580
610
  const declared = discovery?.standalone === true
581
- ? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { labels })
582
- : 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;
583
614
 
584
615
  const modules = [];
585
616
  const skills = [];
@@ -608,7 +639,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
608
639
  const cap = capabilities.find((c) => c.name === name);
609
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) });
610
641
  const layer = layerOf(cap.manifest);
611
- 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; }
612
643
  module = {
613
644
  name, from: { kind: "package", package: id, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
614
645
  manifest: clone(cap.manifest), layer, private: cap.manifest.private === true, dir: cap.dir,
@@ -618,7 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
618
649
  } else {
619
650
  const { row, cap } = lookupMember(discovery, soul, name, from, via, lock);
620
651
  const layer = layerOf(cap.manifest);
621
- 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; }
622
653
  const ref = memberRef(discovery, remote, cap.repoKey);
623
654
  module = {
624
655
  name, from: { kind: "member", repoKey: cap.repoKey, commit: cap.commit ?? row.commit },
@@ -737,6 +768,16 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
737
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 });
738
769
  }
739
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
+
740
781
  // Compatibility floors (soul.compatibility) are constraints on PACKAGE versions.
741
782
  for (const [cap, range] of Object.entries(isObject(definition.compatibility) ? definition.compatibility : {})) {
742
783
  const m = modules.find((x) => x.name === cap);
@@ -768,9 +809,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
768
809
  // decision 3) is live messaging state, not composition: it stays out of both fingerprints too, so a
769
810
  // single-label soul's revision is what it was.
770
811
  // `slotsFrom` (where each filled slot's capability came from) is provenance, like payloadOrigins: outside
771
- // 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).
772
814
  const teams = teamsOf(discovery?.standalone === true ? null : workspace, labels);
773
- 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 });
774
818
  }
775
819
 
776
820
  /** Where a composed capability came from, as the Desktop names it (`layers.<layer>.from`): the soul's own