@awebai/oats 0.27.2 → 0.28.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 +185 -26
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
- package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
- package/capabilities/oats-okf/injects/okf.md +29 -21
- package/capabilities/oats-okf/lib/config.mjs +5 -1
- package/capabilities/oats-okf/lib/consult.mjs +500 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/sources.mjs +15 -53
- package/capabilities/oats-okf/lib/stores.mjs +10 -7
- package/capabilities/oats-okf/lib/worker.mjs +9 -1
- package/capabilities/oats-okf/oats.json +13 -4
- package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- 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 +93 -7
- package/docs/oats-local.schema.json +2 -1
- package/docs/oats-package.schema.json +39 -0
- package/docs/packages.md +67 -3
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/schedules.md +99 -1
- package/docs/souls-and-instances.md +7 -3
- package/docs/workspaces.md +8 -2
- package/lib/core.mjs +22 -4
- package/lib/instance-inspect.mjs +4 -4
- package/lib/instance-resolution.mjs +62 -18
- package/lib/materialize.mjs +13 -0
- package/lib/packages.mjs +90 -6
- package/lib/resolve.mjs +20 -2
- package/lib/schedule.mjs +24 -11
- package/lib/triggers.mjs +545 -0
- package/lib/workspace.mjs +80 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -1501,6 +1501,11 @@ export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
|
|
|
1501
1501
|
return "";
|
|
1502
1502
|
}
|
|
1503
1503
|
export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
|
|
1504
|
+
/** A triggered instance's event, inside its home's .oats/ (lib/triggers.mjs). */
|
|
1505
|
+
export const TRIGGER_EVENT_FILE = "trigger-event.json";
|
|
1506
|
+
/** A prepared soul entry's id: `<repoKey>#<name>` for a member or external soul, `package:<id>#<name>`
|
|
1507
|
+
* for a package soul (stable across the package's versions and independent of its repo). */
|
|
1508
|
+
const preparedSoulIdOf = (entry) => workspaceSoulId(typeof entry.package === "string" ? `package:${entry.package}` : entry.repoKey, entry.name);
|
|
1504
1509
|
/** The soul directory an instance incarnates, as spawn recorded it (instance.json
|
|
1505
1510
|
* `soulDir`): a workspace soul's per-commit copy (agents/<soul>/souls/<commit12>) or
|
|
1506
1511
|
* the read-only soul inside a capability package. It is what every classic
|
|
@@ -1672,7 +1677,10 @@ function readSoul(agentDir, soulDir = soulOf(agentDir)) {
|
|
|
1672
1677
|
}
|
|
1673
1678
|
const soul = soulHarnessField(stripInternalAnnotations(parsed), p);
|
|
1674
1679
|
soul._dir = agentDir;
|
|
1675
|
-
soul
|
|
1680
|
+
// A package soul homes at <package>--<soul> (lib/workspace.mjs packageSoulAgentName): that
|
|
1681
|
+
// directory, not the soul.yaml name, is its agent name, so its instances never share a
|
|
1682
|
+
// member soul's name or roster row.
|
|
1683
|
+
soul.name = basename(agentDir).includes("--") ? basename(agentDir) : (soul.name || basename(agentDir));
|
|
1676
1684
|
return soul;
|
|
1677
1685
|
}
|
|
1678
1686
|
export function findAgent(root, name) {
|
|
@@ -3270,14 +3278,22 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3270
3278
|
// Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
|
|
3271
3279
|
// memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
|
|
3272
3280
|
// messaging integration mints the comms identity. Kernel stays memory-agnostic.
|
|
3273
|
-
const preparedSoulId = o.prepared ?
|
|
3281
|
+
const preparedSoulId = o.prepared ? preparedSoulIdOf(o.prepared.soulEntry) : undefined;
|
|
3282
|
+
// A trigger's event (lib/triggers.mjs): a private copy in the home, named to hooks and the harness
|
|
3283
|
+
// as OATS_TRIGGER_EVENT_FILE. Its PR title/body are never in the task: the soul reads them from here.
|
|
3284
|
+
let triggerEventFile = null;
|
|
3285
|
+
if (o.triggerEvent && typeof o.triggerEvent === "object") {
|
|
3286
|
+
triggerEventFile = join(home, ".oats", TRIGGER_EVENT_FILE);
|
|
3287
|
+
mkdirSync(dirname(triggerEventFile), { recursive: true });
|
|
3288
|
+
writeFileSync(triggerEventFile, JSON.stringify(o.triggerEvent, null, 2) + "\n", { mode: 0o600 });
|
|
3289
|
+
}
|
|
3274
3290
|
// Hooks read the soul the HOME links (the per-commit directory for a workspace
|
|
3275
3291
|
// soul), never the swappable agents/<name>/soul pointer: a provider that pins a
|
|
3276
3292
|
// path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
|
|
3277
3293
|
const hookRes = runLifecycleHooks("spawn", {
|
|
3278
3294
|
home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
|
|
3279
3295
|
workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
|
|
3280
|
-
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent" },
|
|
3296
|
+
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent", ...(triggerEventFile ? { OATS_TRIGGER_EVENT_FILE: triggerEventFile } : {}) },
|
|
3281
3297
|
});
|
|
3282
3298
|
warnings.push(...hookRes.warnings);
|
|
3283
3299
|
// Which capability hooks RAN (in order) and how each ended — recorded on the
|
|
@@ -3498,6 +3514,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3498
3514
|
hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
|
|
3499
3515
|
prompt: LAUNCH_PROMPT,
|
|
3500
3516
|
};
|
|
3517
|
+
if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
|
|
3501
3518
|
const cmdline = renderLaunchRecipe(recipe, { home, instance });
|
|
3502
3519
|
|
|
3503
3520
|
// Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
|
|
@@ -3514,6 +3531,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3514
3531
|
relativeTo: relation ? relativeTo : undefined,
|
|
3515
3532
|
spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
|
|
3516
3533
|
policy: { childSpawns: ownChildPolicy },
|
|
3534
|
+
...(triggerEventFile ? { trigger: { id: o.triggerEvent.trigger, key: o.triggerEvent.key ?? null, source: o.triggerEvent.source, repo: o.triggerEvent.repo, number: o.triggerEvent.number, url: o.triggerEvent.url ?? null, event: o.triggerEvent.event, headSha: o.triggerEvent.headSha ?? null, observedAt: o.triggerEvent.observedAt ?? null, eventFile: triggerEventFile } } : {}),
|
|
3517
3535
|
// K6c: a decision-bound spawn records what bound it, so a retry with the
|
|
3518
3536
|
// same key replays this receipt instead of spawning again.
|
|
3519
3537
|
// The FULL bound decision (placement + effective), exactly as the fence
|
|
@@ -3582,7 +3600,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3582
3600
|
try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
|
|
3583
3601
|
// M5/3a: the workspace's name and the deployment directory are recorded, so a home
|
|
3584
3602
|
// answers them (inspect/operation run --home, OATS_WORKSPACE_NAME) without discovery.
|
|
3585
|
-
meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id:
|
|
3603
|
+
meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: preparedSoulIdOf(o.prepared.soulEntry), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))], ...(typeof o.prepared.soulEntry.package === "string" ? { name: o.prepared.soulEntry.name, qualifiedName: o.prepared.soulEntry.qualifiedName, package: { id: o.prepared.soulEntry.package, version: o.prepared.soulEntry.version, commit: o.prepared.soulEntry.commit, digest: o.prepared.soulEntry.digest, path: o.prepared.soulEntry.path } } : {}) }, layers: layerRows(o.prepared.resolution) };
|
|
3586
3604
|
// Teams contract (decision 6): the eligible teams at spawn, recorded as EVIDENCE beside
|
|
3587
3605
|
// `providers` (never inside that capability-keyed map). A home's hooks and operations get
|
|
3588
3606
|
// the LIVE set (liveTeams); this is what they fall back to when discovery cannot answer.
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
|
|
|
15
15
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
|
|
18
|
-
import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
18
|
+
import { agentDirOf, discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
|
|
21
21
|
import { loadLocal } from "./workspace.mjs";
|
|
@@ -94,7 +94,7 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
|
|
|
94
94
|
// The derived deployment exactly — never an oats-local.yaml found further up.
|
|
95
95
|
const found = loadLocal(deployment);
|
|
96
96
|
if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
|
|
97
|
-
discovery = await discoverOrStandalone(found.local, { remoteOptions });
|
|
97
|
+
discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
|
|
98
98
|
}
|
|
99
99
|
catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
|
|
100
100
|
}
|
|
@@ -140,13 +140,13 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
|
|
|
140
140
|
let discovery = prepared?.discovery ?? null, soulEntry = prepared?.soulEntry ?? null;
|
|
141
141
|
if (!prepared) {
|
|
142
142
|
// Still name the soul (and its member) when its resolution is refused.
|
|
143
|
-
discovery = await discoverOrStandalone(found.local, { remoteOptions });
|
|
143
|
+
discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
|
|
144
144
|
soulEntry = findSoulEntry(discovery, soul);
|
|
145
145
|
}
|
|
146
146
|
const res = prepared?.resolution;
|
|
147
147
|
// A refused resolution still names its eligible teams: the labels and the workspace are known.
|
|
148
148
|
const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
|
|
149
|
-
const cached = soulEntry?.commit ? join(deployment, "agents", soulEntry
|
|
149
|
+
const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
|
|
150
150
|
return {
|
|
151
151
|
kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
|
|
152
152
|
subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },
|
|
@@ -28,7 +28,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { spawnSync } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
31
|
+
import { readLock, LOCK_FILE, readPackageManifests, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
|
|
32
32
|
import * as defaultRemote from "./remote.mjs";
|
|
33
33
|
import { parseRepoRef } from "./remote.mjs";
|
|
34
34
|
|
|
@@ -77,30 +77,58 @@ export function parseProviderFlags(pairs) {
|
|
|
77
77
|
return JSON.parse(JSON.stringify(out));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
80
|
+
/** The qualified name of a discovered soul: `<package>/<soul>` for a package soul,
|
|
81
|
+
* `<member name>/<soul>` for a member's, `<source repo name>/<soul>` for an external one. */
|
|
82
|
+
export function qualifiedSoulName(entry) {
|
|
83
|
+
if (typeof entry?.package === "string") return `${entry.package}/${entry.name}`;
|
|
84
|
+
return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Find the soul named `name` in a discovery: confirmed members, external souls and package
|
|
88
|
+
* souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
|
|
89
|
+
* qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
|
|
90
|
+
* or an external source, `<package>/<soul>` a package soul. */
|
|
82
91
|
export function findSoulEntry(discovery, name) {
|
|
83
92
|
const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
|
|
84
93
|
const hits = [];
|
|
85
94
|
// A standalone view's one row is the repo's own (unconfirmed by definition — the
|
|
86
95
|
// workspace could not be read); resolveSoul admits exactly that case.
|
|
87
96
|
const standaloneOwn = discovery.standalone === true ? discovery.key : null;
|
|
97
|
+
const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
|
|
88
98
|
for (const m of discovery.members || []) {
|
|
89
99
|
if (!m.confirmed && m.key !== standaloneOwn) continue;
|
|
90
100
|
for (const s of m.souls || []) {
|
|
91
|
-
if (s.name !== soulPart) continue;
|
|
92
|
-
if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
|
|
101
|
+
if (s.name !== soulPart || !memberMatches(m.key)) continue;
|
|
93
102
|
hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
for (const x of discovery.external || []) {
|
|
97
106
|
if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
|
|
98
107
|
}
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
for (const s of discovery.packageSouls || []) {
|
|
109
|
+
if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
|
|
110
|
+
}
|
|
111
|
+
if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
|
|
112
|
+
if (hits.length > 1) {
|
|
113
|
+
const qualified = hits.map(qualifiedSoulName);
|
|
114
|
+
throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} is ${hits.length} souls (${qualified.join(", ")}); name one of them`, { name, repos: hits.map((h) => h.repoKey), qualified });
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(hits[0].collides)) throw err("E_SOUL_AMBIGUOUS", `package souls ${hits[0].collides.join(" and ")} would share the agent directory agents/${hits[0].agentName}/ — keep one of the packages in the workspace's packages:`, { name, agentDir: hits[0].agentName, qualified: hits[0].collides });
|
|
101
117
|
return hits[0];
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
/** The agents-root directory of a discovered soul: a package soul's is `<package>--<soul>`
|
|
121
|
+
* (packageSoulAgentName), every other soul's is its name. */
|
|
122
|
+
export function agentDirOf(entry) { return typeof entry?.agentName === "string" ? entry.agentName : entry?.name; }
|
|
123
|
+
|
|
124
|
+
/** `oats-local.yaml` `souls.disabled` (not run on this machine): an entry names a soul by its
|
|
125
|
+
* bare name (every soul of that name) or its qualified name. → the matching entry or null. */
|
|
126
|
+
export function disabledEntry(local, soulEntry) {
|
|
127
|
+
const list = Array.isArray(local?.souls?.disabled) ? local.souls.disabled : [];
|
|
128
|
+
const qualified = qualifiedSoulName(soulEntry);
|
|
129
|
+
return list.find((d) => d === soulEntry.name || d === qualified) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
|
|
106
134
|
* state, not frozen composition: the soul's labels are read from its repository NOW and each
|
|
@@ -117,6 +145,8 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
|
117
145
|
const soul = meta?.workspace?.soul;
|
|
118
146
|
// A capability agent's soul is its providing module's: no team labels, nothing to re-read.
|
|
119
147
|
if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
|
|
148
|
+
// A package soul's labels are pinned with the package: the spawn-time record is its answer.
|
|
149
|
+
if (soul.package && typeof soul.package === "object") return recorded("package-soul");
|
|
120
150
|
const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
|
|
121
151
|
try {
|
|
122
152
|
const found = loadLocal(deployment);
|
|
@@ -162,9 +192,13 @@ function swapSoulPointer(soulDir, target) {
|
|
|
162
192
|
/** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
|
|
163
193
|
async function fetchSoulSource(prepared, dest) {
|
|
164
194
|
const e = prepared.soulEntry;
|
|
165
|
-
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
195
|
+
const ref = typeof e.ref === "string" ? e.ref : e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
166
196
|
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
167
|
-
await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks:
|
|
197
|
+
const { digest } = await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: SOUL_ALIAS_SYMLINK });
|
|
198
|
+
// A package soul is what the lock recorded at sync: the same bytes at the locked commit.
|
|
199
|
+
if (typeof e.package === "string" && digest !== e.digest) {
|
|
200
|
+
throw err("E_PACKAGE_INTEGRITY", `package soul ${e.qualifiedName} v${e.version} @ ${String(e.commit).slice(0, 12)}: content digest ${digest} does not match the locked ${e.digest} — run \`oats sync\` (a lock that was edited: remove the entry and sync again)`, { package: e.package, soul: e.name, version: e.version, commit: e.commit, why: "soul-digest", locked: e.digest, observed: digest });
|
|
201
|
+
}
|
|
168
202
|
if (!existsSync(join(dest, "soul.yaml")) || !existsSync(join(dest, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
|
|
169
203
|
if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
|
|
170
204
|
}
|
|
@@ -176,7 +210,7 @@ async function fetchSoulSource(prepared, dest) {
|
|
|
176
210
|
* that temporary copy; `fetched` says whether the preview had to fetch. */
|
|
177
211
|
export async function previewWorkspaceSoul(prepared, agentsRoot) {
|
|
178
212
|
const e = prepared.soulEntry;
|
|
179
|
-
const commitDir = join(agentsRoot, e
|
|
213
|
+
const commitDir = join(agentsRoot, agentDirOf(e), SOULS_DIR, commit12(e.commit));
|
|
180
214
|
if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
|
|
181
215
|
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
|
|
182
216
|
const cleanup = () => rmSync(tmp, { recursive: true, force: true });
|
|
@@ -202,7 +236,7 @@ export async function previewWorkspaceSoul(prepared, agentsRoot) {
|
|
|
202
236
|
* Returns the PER-COMMIT directory (what a home should link). */
|
|
203
237
|
export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
204
238
|
const e = prepared.soulEntry;
|
|
205
|
-
const agentDir = join(agentsRoot, e
|
|
239
|
+
const agentDir = join(agentsRoot, agentDirOf(e)); const soulDir = join(agentDir, "soul");
|
|
206
240
|
const soulsDir = join(agentDir, SOULS_DIR);
|
|
207
241
|
const stamp = join(agentDir, SOUL_SOURCE_STAMP);
|
|
208
242
|
const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
|
|
@@ -248,7 +282,8 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
248
282
|
if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
|
|
249
283
|
const cur = readStamp();
|
|
250
284
|
if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
|
|
251
|
-
|
|
285
|
+
const pkg = typeof e.package === "string" ? { package: e.package, version: e.version } : {};
|
|
286
|
+
writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, ...pkg, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
252
287
|
}
|
|
253
288
|
return target;
|
|
254
289
|
}
|
|
@@ -389,8 +424,10 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
389
424
|
const local = found.local;
|
|
390
425
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
391
426
|
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
392
|
-
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
|
|
427
|
+
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
|
|
393
428
|
const soulEntry = findSoulEntry(discovery, soulName);
|
|
429
|
+
const disabled = disabledEntry(local, soulEntry);
|
|
430
|
+
if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
|
|
394
431
|
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
395
432
|
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
|
|
396
433
|
}
|
|
@@ -402,6 +439,11 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
402
439
|
const ACCESS_REASONS = new Set(["auth", "not-found"]);
|
|
403
440
|
const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
|
|
404
441
|
|
|
442
|
+
/** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
|
|
443
|
+
export function deploymentLock(deployment) {
|
|
444
|
+
return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
405
447
|
/** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
|
|
406
448
|
* repo must declare that workspace (oats-membership.yaml). A lone repo with no
|
|
407
449
|
* backlink is not a member of anything and never becomes a capability source. */
|
|
@@ -424,21 +466,23 @@ function requireMembership(repo, ref) {
|
|
|
424
466
|
* readable in this access context (auth / not-found).
|
|
425
467
|
* A transient failure reading the host (network, timeout) is rethrown as-is.
|
|
426
468
|
*/
|
|
427
|
-
export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
|
|
469
|
+
export async function discoverOrStandalone(local, { lock, deployment, remoteOptions, remote } = {}) {
|
|
428
470
|
const ro = { remoteOptions, remote };
|
|
471
|
+
// Package souls are listed from the deployment's lock (the `lock` given, else `<deployment>/oats-lock.json`).
|
|
472
|
+
if (lock === undefined) lock = typeof deployment === "string" ? deploymentLock(deployment) : null;
|
|
429
473
|
if (typeof local.standalone === "string" && local.standalone) {
|
|
430
474
|
const repo = await discoverRepo(local.standalone, ro);
|
|
431
475
|
requireMembership(repo, local.standalone);
|
|
432
476
|
return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
|
|
433
477
|
}
|
|
434
|
-
try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
|
|
478
|
+
try { return await discoverWorkspace(local.workspace, { local, lock, ...ro }); }
|
|
435
479
|
catch (e) {
|
|
436
480
|
if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
|
|
437
481
|
// The ref is a repository without oats-workspace.yaml: is it a member whose
|
|
438
482
|
// workspace we cannot read? Then the standalone view is what the operator gets.
|
|
439
483
|
const repo = await discoverRepo(local.workspace, ro);
|
|
440
484
|
if (!repo.membership) throw e;
|
|
441
|
-
try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
|
|
485
|
+
try { return await discoverWorkspace(repo.membership.workspace, { local, lock, ...ro }); }
|
|
442
486
|
catch (inner) {
|
|
443
487
|
// Only an ACCESS failure on the host means "standalone"; anything else
|
|
444
488
|
// (network, timeout, a broken workspace file) is the caller's to see.
|
|
@@ -499,7 +543,7 @@ export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg
|
|
|
499
543
|
const local = found.local;
|
|
500
544
|
const deployment = dirname(found.path);
|
|
501
545
|
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
502
|
-
const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
|
|
546
|
+
const view = discovery ?? await discoverOrStandalone(local, { lock, remoteOptions });
|
|
503
547
|
const cap = agent.capability;
|
|
504
548
|
let module, payload, origins, localModules = null;
|
|
505
549
|
const anchor = pkg ? null : agent._manifestSource;
|
|
@@ -652,7 +696,7 @@ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOp
|
|
|
652
696
|
const found = loadLocal(contextDir);
|
|
653
697
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
654
698
|
const remote = defaultRemote;
|
|
655
|
-
const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
|
|
699
|
+
const view = discovery === undefined ? await discoverOrStandalone(found.local, { deployment, remoteOptions }) : discovery;
|
|
656
700
|
// A confirmed member's own (non-private) capability first: membership is its trust
|
|
657
701
|
// decision, exactly as for a soul's `from: <member>` module.
|
|
658
702
|
for (const row of view?.members || []) {
|
package/lib/materialize.mjs
CHANGED
|
@@ -584,12 +584,25 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
|
|
|
584
584
|
* (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
|
|
585
585
|
* by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
|
|
586
586
|
* not reported missing. Returns null for a home without a recorded workspace soul (classic).
|
|
587
|
+
* A PACKAGE soul (`workspace.soul.package`) is compared with the discovery's package souls: the
|
|
588
|
+
* pin moved → "moved"; the package no longer locked/declared → "missing" ("package-absent"); the
|
|
589
|
+
* soul no longer shipped by the package → "missing" ("soul-absent").
|
|
587
590
|
*/
|
|
588
591
|
export function soulDriftOf(instanceJson, discovery) {
|
|
589
592
|
const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
|
|
590
593
|
if (!soul || typeof soul.repoKey !== "string") return null;
|
|
591
594
|
const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
|
|
592
595
|
const commit = typeof soul.commit === "string" ? soul.commit : null;
|
|
596
|
+
if (plainObject(soul.package) && typeof soul.package.id === "string") {
|
|
597
|
+
// A package soul: "moved" means the package pin moved (another version/commit is locked now).
|
|
598
|
+
const pkg = { package: soul.package.id, version: soul.package.version ?? null };
|
|
599
|
+
const soulName = typeof soul.name === "string" ? soul.name : name;
|
|
600
|
+
const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
|
|
601
|
+
const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
|
|
602
|
+
const now = listed.find((s) => s.name === soulName) || null;
|
|
603
|
+
if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
|
|
604
|
+
return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
|
|
605
|
+
}
|
|
593
606
|
const members = Array.isArray(discovery?.members) ? discovery.members : [];
|
|
594
607
|
const member = members.find((m) => m && m.key === soul.repoKey) || null;
|
|
595
608
|
const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
|
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
|
|
@@ -419,23 +490,36 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
|
|
|
419
490
|
// The lock's capability list must still be what the package declares at that commit —
|
|
420
491
|
// spawn refuses a mismatch (E_PACKAGE_INTEGRITY why:capabilities), so sync must too, or
|
|
421
492
|
// a bad list would pass every sync and fail every spawn.
|
|
422
|
-
const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
|
|
493
|
+
const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
|
|
423
494
|
const listed = capabilities.map((c) => c.name).sort(), locked = [...old.capabilities].sort();
|
|
424
495
|
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
425
496
|
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
426
497
|
`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
498
|
{ ...details, version, why: "capabilities", listed, locked });
|
|
428
499
|
}
|
|
429
|
-
|
|
430
|
-
|
|
500
|
+
// The lock's souls must be what the package ships at that commit: the same names, paths and
|
|
501
|
+
// digests. A lock written before 0.28.0 carries no `souls` — it is filled in, not refused.
|
|
502
|
+
const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
|
|
503
|
+
if (old.souls !== undefined) {
|
|
504
|
+
const show = (list) => list.map((x) => `${x.name}@${x.path}:${x.digest.slice(7, 19)}`).sort().join(", ");
|
|
505
|
+
if (show(old.souls) !== show(souls)) {
|
|
506
|
+
throw oatsError("E_PACKAGE_INTEGRITY",
|
|
507
|
+
`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`,
|
|
508
|
+
{ ...details, version, why: "souls", listed: souls, locked: old.souls });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const { approved: _dropped, souls: _souls, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
|
|
512
|
+
nextPackages[id] = { ...kept, ...(souls.length ? { souls } : {}) };
|
|
431
513
|
continue;
|
|
432
514
|
}
|
|
433
515
|
|
|
434
|
-
const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
|
|
516
|
+
const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
|
|
435
517
|
const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
|
|
518
|
+
const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
|
|
436
519
|
nextPackages[id] = {
|
|
437
520
|
source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
|
|
438
521
|
capabilities: capabilities.map((c) => c.name).sort(),
|
|
522
|
+
...(souls.length ? { souls } : {}),
|
|
439
523
|
};
|
|
440
524
|
changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit });
|
|
441
525
|
}
|
package/lib/resolve.mjs
CHANGED
|
@@ -472,6 +472,15 @@ function lookupPackage(lock, name, via, soul, declared) {
|
|
|
472
472
|
return providing;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
/** `from: here` in a package soul: the capability must be one its OWN locked package provides. */
|
|
476
|
+
function ownPackageEntry(lock, name, via, soul, id) {
|
|
477
|
+
const where = { capability: name, from: "here", soul: soul.name, via, package: id };
|
|
478
|
+
const entry = isObject(lock?.packages) ? lock.packages[id] : null;
|
|
479
|
+
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" });
|
|
480
|
+
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] });
|
|
481
|
+
return { id, entry };
|
|
482
|
+
}
|
|
483
|
+
|
|
475
484
|
/** 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
485
|
export function packageRef(id, entry, catalog, remote) {
|
|
477
486
|
const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
|
|
@@ -582,8 +591,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
582
591
|
const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
|
|
583
592
|
for (const { name, from, via } of declared) {
|
|
584
593
|
let module;
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
const ownPackage = from === "here" && typeof soulEntry.package === "string";
|
|
595
|
+
if (from === "package" || ownPackage) {
|
|
596
|
+
// `from: here` in a package soul is its own package, at the locked commit (§2.2).
|
|
597
|
+
const { id, entry } = ownPackage
|
|
598
|
+
? ownPackageEntry(lock, name, via, soul, soulEntry.package)
|
|
599
|
+
: lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
|
|
587
600
|
const ref = packageRef(id, entry, catalog, remote);
|
|
588
601
|
const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
|
|
589
602
|
const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
|
|
@@ -783,6 +796,11 @@ function assertSoulDiscovered(discovery, soulEntry) {
|
|
|
783
796
|
const { name, repoKey } = soulEntry;
|
|
784
797
|
const where = { soul: name, repoKey };
|
|
785
798
|
const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
|
|
799
|
+
if (typeof soulEntry.package === "string") {
|
|
800
|
+
// A package soul: the discovery listed it from the lock, at this package's locked commit.
|
|
801
|
+
if ((discovery?.packageSouls || []).some((s) => sameSoul(s) && s.package === soulEntry.package)) return;
|
|
802
|
+
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" });
|
|
803
|
+
}
|
|
786
804
|
if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
|
|
787
805
|
const row = memberRow(discovery, repoKey);
|
|
788
806
|
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" });
|