@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.
- package/bin/oats.mjs +445 -96
- package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
- package/capabilities/oats-okf/injects/okf.md +36 -28
- package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
- package/capabilities/oats-okf/lib/config.mjs +6 -1
- package/capabilities/oats-okf/lib/consult.mjs +496 -0
- package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
- package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
- package/capabilities/oats-okf/lib/sources.mjs +42 -55
- package/capabilities/oats-okf/lib/stores.mjs +19 -11
- package/capabilities/oats-okf/lib/worker.mjs +90 -8
- package/capabilities/oats-okf/oats.json +24 -9
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
- package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
- package/capabilities/oats-okf-harvest/oats.json +26 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
- package/capabilities/oats-okf-maintenance/oats.json +21 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
- package/capabilities/oats-review/injects/review.md +3 -2
- package/capabilities/oats-review/oats.json +3 -4
- package/docs/capabilities.md +41 -9
- package/docs/capability-manifest.schema.json +0 -7
- package/docs/design/2026-09-24-phase-d-plan.md +11 -0
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
- package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
- package/docs/desktop-cli-api.md +342 -10
- package/docs/implementation.md +1 -1
- package/docs/knowledge-capability-authoring.md +8 -2
- package/docs/knowledge-reference/package-craft.md +8 -5
- package/docs/knowledge.md +101 -0
- package/docs/oats-local.schema.json +33 -2
- package/docs/oats-package.schema.json +39 -0
- package/docs/official-catalog.md +7 -4
- package/docs/packages.md +76 -6
- package/docs/release-lane.md +1 -1
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/release-notes/v0.29.0.md +240 -0
- package/docs/schedules.md +230 -4
- package/docs/souls-and-instances.md +11 -9
- package/docs/workspaces.md +18 -3
- package/lib/automations.mjs +369 -0
- package/lib/core.mjs +87 -158
- package/lib/instance-inspect.mjs +16 -8
- package/lib/instance-resolution.mjs +90 -197
- package/lib/materialize.mjs +18 -7
- package/lib/operator-dispatch.mjs +1 -2
- package/lib/packages.mjs +107 -6
- package/lib/remote.mjs +21 -1
- package/lib/resolve.mjs +71 -9
- package/lib/schedule.mjs +228 -45
- package/lib/triggers.mjs +678 -0
- package/lib/workspace.mjs +81 -4
- package/package-catalog.json +6 -4
- package/package.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
- package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
- /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>", …]
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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" });
|