@awebai/oats 0.25.9 → 0.26.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/README.md +8 -6
- package/bin/oats.mjs +576 -1714
- package/capabilities/oats-authoring/oats-package.json +2 -2
- package/capabilities/oats-authoring/oats.json +2 -2
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
- package/capabilities/oats-aweb/injects/aweb.md +7 -2
- package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
- package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
- package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
- package/capabilities/oats-aweb/oats.json +8 -4
- package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
- package/capabilities/oats-jira/oats.json +2 -2
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
- package/capabilities/oats-linear/oats.json +2 -2
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
- package/capabilities/oats-review/oats.json +3 -2
- package/docs/capabilities.md +218 -47
- package/docs/capability-manifest.schema.json +13 -4
- package/docs/configuration.md +17 -5
- package/docs/conventions.md +16 -26
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
- package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
- package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
- package/docs/design/2026-09-24-phase-d-plan.md +57 -0
- package/docs/design/2026-09-25-teams-contract.md +226 -0
- package/docs/design/README.md +3 -3
- package/docs/design/launch-configurations.md +20 -16
- package/docs/design/operations-contract.md +27 -10
- package/docs/desktop-cli-api.md +537 -261
- package/docs/desktop-instance-start.md +1 -1
- package/docs/desktop.md +7 -13
- package/docs/execution-targets.md +16 -18
- package/docs/first-team.md +14 -17
- package/docs/implementation.md +28 -59
- package/docs/integrations.md +64 -33
- package/docs/knowledge-capability-authoring.md +1 -1
- package/docs/knowledge-reference/package-craft.md +10 -8
- package/docs/knowledge-theory.md +1 -1
- package/docs/knowledge.md +10 -11
- package/docs/layers.md +16 -17
- package/docs/oats-local.schema.json +29 -1
- package/docs/oats-membership.schema.json +5 -3
- package/docs/oats-package.schema.json +2 -2
- package/docs/oats-workspace.schema.json +1 -1
- package/docs/{official-marketplace.md → official-catalog.md} +15 -16
- package/docs/packages.md +75 -52
- package/docs/release-notes/v0.22.0.md +1 -1
- package/docs/release-notes/v0.23.1.md +1 -1
- package/docs/release-notes/v0.26.0.md +670 -0
- package/docs/schedules.md +48 -126
- package/docs/soul.schema.json +11 -4
- package/docs/souls-and-instances.md +56 -43
- package/docs/workspaces.md +80 -58
- package/injects/instance-boundary.md +1 -1
- package/injects/work-attached.md +1 -1
- package/injects/work-workspace.md +2 -2
- package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
- package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
- package/lib/capability-contract.mjs +110 -0
- package/lib/config-data.mjs +2 -2
- package/lib/core.mjs +700 -4824
- package/lib/digest.mjs +12 -0
- package/lib/instance-inspect.mjs +396 -0
- package/lib/instance-lifecycle.mjs +3 -4
- package/lib/instance-resolution.mjs +212 -26
- package/lib/instruction-composition.mjs +0 -20
- package/lib/materialize.mjs +6 -4
- package/lib/operator-dispatch.mjs +33 -13
- package/lib/packages.mjs +25 -190
- package/lib/provider-binding.mjs +4 -2
- package/lib/provider-reasons.mjs +3 -68
- package/lib/resolve.mjs +204 -68
- package/lib/schedule.mjs +97 -272
- package/lib/servers.mjs +13 -13
- package/lib/{portable-shape.mjs → shape.mjs} +4 -3
- package/lib/tree-copy.mjs +44 -0
- package/lib/workspace.mjs +125 -20
- package/package-catalog.json +6 -6
- package/package.json +1 -1
- package/skills/integration-authoring/SKILL.md +48 -40
- package/skills/oats-getting-started/SKILL.md +105 -110
- package/skills/oats-support/SKILL.md +2 -2
- package/skills/soul-craft/SKILL.md +13 -6
- package/bin/oats-pi-sdk-host.mjs +0 -17
- package/docs/2026-09-03-architecture-proposal.md +0 -642
- package/docs/artifact-approvals.schema.json +0 -7
- package/docs/captured-invocation-context.schema.json +0 -7
- package/docs/captured-resolution.schema.json +0 -7
- package/docs/design/package-engine-contract.md +0 -813
- package/docs/design/package-runtime-api.md +0 -588
- package/docs/desktop-succession.md +0 -57
- package/docs/execution-capsule.schema.json +0 -108
- package/docs/first-team-demo.md +0 -92
- package/docs/knowledge-migration.md +0 -147
- package/docs/migration-from-oas.md +0 -103
- package/docs/oats-config.schema.json +0 -172
- package/docs/oats-lock-v3.schema.json +0 -7
- package/docs/oats-lock.schema.json +0 -175
- package/docs/operating-team-migration.md +0 -470
- package/docs/portable.schema.json +0 -2512
- package/docs/provider-check-input.schema.json +0 -7
- package/docs/rebuild-to-v2.md +0 -511
- package/docs/workspace-adoption.md +0 -74
- package/injects/framework-workspace.md +0 -7
- package/injects/local-soul.md +0 -19
- package/injects/oats-portable.md +0 -20
- package/injects/oats.md +0 -11
- package/injects/portable-instance-boundary.md +0 -39
- package/injects/portable-work-directory.md +0 -29
- package/lib/artifact-approvals.mjs +0 -120
- package/lib/artifact-tree.mjs +0 -141
- package/lib/capability-artifacts.mjs +0 -179
- package/lib/capability-execution.mjs +0 -15
- package/lib/capability-inputs.mjs +0 -39
- package/lib/capability-provenance.mjs +0 -231
- package/lib/captured-action-shape.mjs +0 -21
- package/lib/captured-admission-shape.mjs +0 -20
- package/lib/captured-binding-file.mjs +0 -36
- package/lib/captured-dispatch.mjs +0 -66
- package/lib/captured-instance-index.mjs +0 -277
- package/lib/captured-invocation-context.mjs +0 -130
- package/lib/captured-launch-request.mjs +0 -66
- package/lib/captured-operation-process.mjs +0 -15
- package/lib/captured-pi-custody.mjs +0 -29
- package/lib/captured-pi-host.mjs +0 -167
- package/lib/captured-pi-outcome.mjs +0 -172
- package/lib/captured-resolutions.mjs +0 -275
- package/lib/captured-scaffold.mjs +0 -87
- package/lib/captured-selector.mjs +0 -28
- package/lib/captured-session-backend.mjs +0 -52
- package/lib/captured-source-receipt-file.mjs +0 -72
- package/lib/helper-injection-policy.mjs +0 -104
- package/lib/legacy-lock-codec.mjs +0 -106
- package/lib/manifest-settings.mjs +0 -84
- package/lib/package-closure.mjs +0 -48
- package/lib/package-materialization.mjs +0 -83
- package/lib/pi-sdk-host.mjs +0 -229
- package/lib/portable-artifacts.mjs +0 -115
- package/lib/portable-choices.mjs +0 -82
- package/lib/portable-composition.mjs +0 -136
- package/lib/portable-digest.mjs +0 -105
- package/lib/portable-identity.mjs +0 -40
- package/lib/portable-lock.mjs +0 -117
- package/lib/portable-onboarding-request.mjs +0 -49
- package/lib/portable-onboarding.mjs +0 -256
- package/lib/portable-package-preparation.mjs +0 -188
- package/lib/portable-policy.mjs +0 -44
- package/lib/portable-soul.mjs +0 -42
- package/lib/portable-state.mjs +0 -80
- package/lib/prepare-composition.mjs +0 -170
- package/lib/prepared-bindings.mjs +0 -92
- package/lib/prepared-resources.mjs +0 -127
- package/lib/provider-binding-broker.mjs +0 -65
- package/lib/provider-binding-wire.mjs +0 -116
- package/lib/readiness.mjs +0 -225
- package/lib/repository-observation.mjs +0 -226
- package/lib/resolution-shape.mjs +0 -393
- package/lib/schedule-capsule.mjs +0 -206
- package/lib/soul-constraints.mjs +0 -40
- package/lib/source-projection.mjs +0 -84
- package/lib/source-spec.mjs +0 -189
- package/lib/workspace-definition.mjs +0 -126
- package/lib/workspace-discovery.mjs +0 -146
- package/skills/oats/SKILL.md +0 -162
- package/skills/oats-config/SKILL.md +0 -164
- package/skills/oats-packages/SKILL.md +0 -184
- package/skills/oats-portable/SKILL.md +0 -115
- package/skills/oats-portable-artifacts/SKILL.md +0 -63
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A spawn is: read this machine's `oats-local.yaml` → discover the workspace over
|
|
5
5
|
* its Git remotes in the operator's access context → find the soul among the
|
|
6
6
|
* confirmed members (or external souls) → resolve every capability by `from:`
|
|
7
|
-
* (member = latest state, package = the locked
|
|
7
|
+
* (member = latest state, package = the locked version) → materialize
|
|
8
8
|
* each capability WHOLE into the new home (`.oats/modules/`, `.agents/skills/`) →
|
|
9
9
|
* compose AGENTS.md → launch the harness normally.
|
|
10
10
|
*
|
|
@@ -16,14 +16,16 @@
|
|
|
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 } from "node:fs";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync, readlinkSync, copyFileSync, chmodSync } from "node:fs";
|
|
20
20
|
import { join, resolve as resolvePath, dirname, basename, relative, isAbsolute, sep } from "node:path";
|
|
21
21
|
import { oatsError } from "./errors.mjs";
|
|
22
|
-
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./workspace.mjs";
|
|
23
|
-
import { resolveSoul, packageRef } from "./resolve.mjs";
|
|
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";
|
|
24
25
|
import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
25
26
|
import { fetchRemoteTree } from "./remote.mjs";
|
|
26
|
-
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
|
|
27
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
27
29
|
import { spawnSync } from "node:child_process";
|
|
28
30
|
import { randomBytes } from "node:crypto";
|
|
29
31
|
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
@@ -99,10 +101,39 @@ export function findSoulEntry(discovery, name) {
|
|
|
99
101
|
return hits[0];
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
|
|
106
|
+
* state, not frozen composition: the soul's labels are read from its repository NOW and each
|
|
107
|
+
* label's payload from the workspace's current `messaging`. The home's modules and skills are
|
|
108
|
+
* untouched. No workspace discovery: exactly two repository reads — the workspace host at its
|
|
109
|
+
* current commit (observeWorkspace) and the soul's own repo (observeSoulLabels).
|
|
110
|
+
* → { teams, source: "live" } — or, when that cannot answer (the host or the soul's repo is
|
|
111
|
+
* unreachable, the soul is no longer listed, a standalone deployment), the spawn-time record:
|
|
112
|
+
* { teams: <record, or null when none>, source: "recorded", reason }.
|
|
113
|
+
* `source` travels as OATS_TEAMS_SOURCE: a provider leaves a joined team only on a `live` answer.
|
|
114
|
+
*/
|
|
115
|
+
export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
|
|
116
|
+
const recorded = (reason, error) => ({ teams: Array.isArray(meta?.teams) ? meta.teams : null, source: "recorded", reason, ...(error ? { error } : {}) });
|
|
117
|
+
const soul = meta?.workspace?.soul;
|
|
118
|
+
// A capability agent's soul is its providing module's: no team labels, nothing to re-read.
|
|
119
|
+
if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
|
|
120
|
+
const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
|
|
121
|
+
try {
|
|
122
|
+
const found = loadLocal(deployment);
|
|
123
|
+
if (!found.path || resolvePath(dirname(found.path)) !== resolvePath(deployment)) return recorded("no-deployment");
|
|
124
|
+
if (typeof found.local.standalone === "string" && found.local.standalone) return recorded("standalone");
|
|
125
|
+
const wsObs = await observeWorkspace(found.local.workspace, { remoteOptions, remote });
|
|
126
|
+
const labels = await observeSoulLabels(wsObs, { name: meta.agent, repoKey: soul.repoKey }, { remoteOptions, remote });
|
|
127
|
+
if (labels === null) return recorded("soul-not-listed");
|
|
128
|
+
return { teams: teamsOf(wsObs.workspace, labels), source: "live" };
|
|
129
|
+
} catch (e) { return recorded("unreadable", { code: e?.code || "E_REMOTE_UNREADABLE", message: e?.message ?? String(e) }); }
|
|
130
|
+
}
|
|
131
|
+
|
|
102
132
|
/** The per-commit soul cache under an agent directory: `<agentDir>/souls/<commit12>/`.
|
|
103
133
|
* Each entry is IMMUTABLE once written (fetched to staging, renamed in) and is never
|
|
104
|
-
* removed by the kernel — a running instance
|
|
105
|
-
*
|
|
134
|
+
* removed by the kernel — a running instance records its own commit's directory
|
|
135
|
+
* (realpath, instance.json `soulDir`, handed to hooks as OATS_SOUL), so nothing can
|
|
136
|
+
* change under it. */
|
|
106
137
|
export const SOULS_DIR = "souls";
|
|
107
138
|
export const SOUL_SOURCE_STAMP = ".oats-soul-source.json";
|
|
108
139
|
const commit12 = (c) => String(c || "").slice(0, 12) || "unknown";
|
|
@@ -128,13 +159,40 @@ function swapSoulPointer(soulDir, target) {
|
|
|
128
159
|
} catch (x) { try { rmSync(tmp, { force: true }); } catch { /* nothing */ } throw x; }
|
|
129
160
|
}
|
|
130
161
|
|
|
162
|
+
/** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
|
|
163
|
+
async function fetchSoulSource(prepared, dest) {
|
|
164
|
+
const e = prepared.soulEntry;
|
|
165
|
+
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
166
|
+
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
167
|
+
await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
|
|
168
|
+
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
|
+
if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A preview's soul source. A preview writes NOTHING in the deployment (K6b), so it
|
|
173
|
+
* never populates the per-commit cache or moves the `soul` pointer: it reads the
|
|
174
|
+
* cache entry for the prepared commit when one is already complete, else fetches
|
|
175
|
+
* the source into a temporary directory outside the deployment. `cleanup` removes
|
|
176
|
+
* that temporary copy; `fetched` says whether the preview had to fetch. */
|
|
177
|
+
export async function previewWorkspaceSoul(prepared, agentsRoot) {
|
|
178
|
+
const e = prepared.soulEntry;
|
|
179
|
+
const commitDir = join(agentsRoot, e.name, SOULS_DIR, commit12(e.commit));
|
|
180
|
+
if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
|
|
181
|
+
const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
|
|
182
|
+
const cleanup = () => rmSync(tmp, { recursive: true, force: true });
|
|
183
|
+
try { await fetchSoulSource(prepared, join(tmp, "soul")); }
|
|
184
|
+
catch (x) { cleanup(); throw x; }
|
|
185
|
+
return { soulDir: join(tmp, "soul"), fetched: true, cleanup };
|
|
186
|
+
}
|
|
187
|
+
|
|
131
188
|
/** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into the
|
|
132
189
|
* per-commit cache `<agentsRoot>/<name>/souls/<commit12>/` and point the classic
|
|
133
190
|
* `<agentsRoot>/<name>/soul` at it (a SYMLINK, swapped atomically), so the classic
|
|
134
191
|
* spawn skeleton, findAgent, doctor … keep reading "current" from the usual place
|
|
135
192
|
* while every spawned home links its OWN commit's directory (decision 7: an
|
|
136
|
-
* instance never changes under itself — a
|
|
137
|
-
*
|
|
193
|
+
* instance never changes under itself — a later spawn may fetch a new commit and
|
|
194
|
+
* move the pointer, but no directory an instance links is ever touched; a preview
|
|
195
|
+
* never calls this, see previewWorkspaceSoul).
|
|
138
196
|
*
|
|
139
197
|
* Idempotent per (repo, commit): an existing complete entry is reused (never
|
|
140
198
|
* refetched, never rewritten); an entry that lost its soul.yaml is moved aside and
|
|
@@ -173,13 +231,9 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
173
231
|
renameSync(commitDir, `${commitDir}.damaged-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
174
232
|
}
|
|
175
233
|
if (!complete) {
|
|
176
|
-
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
177
234
|
const staging = join(soulsDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
178
235
|
try {
|
|
179
|
-
|
|
180
|
-
await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
|
|
181
|
-
if (!existsSync(join(staging, "soul.yaml")) || !existsSync(join(staging, "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 });
|
|
182
|
-
if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
|
|
236
|
+
await fetchSoulSource(prepared, staging);
|
|
183
237
|
try { renameSync(staging, commitDir); }
|
|
184
238
|
catch (x) {
|
|
185
239
|
// Lost a race with a concurrent fetch of the same commit: theirs is the same bytes.
|
|
@@ -397,7 +451,114 @@ export async function discoverOrStandalone(local, { remoteOptions, remote } = {}
|
|
|
397
451
|
/** Materialize a prepared resolution into `home`. Called by spawnInstance after
|
|
398
452
|
* the home directory exists and before the harness is launched. */
|
|
399
453
|
export async function materializePrepared(prepared, home) {
|
|
400
|
-
|
|
454
|
+
const localModules = prepared.localModules || null;
|
|
455
|
+
// A capability agent's module copied from its anchor's verified copy (prepareCapabilityAgent):
|
|
456
|
+
// materialize still checks the copy's digest against the one the anchor recorded.
|
|
457
|
+
const fetch = localModules ? async (ref, commit, dir, dest, options) => {
|
|
458
|
+
const src = localModules[basename(dest)];
|
|
459
|
+
if (!src) return defaultRemote.fetchRemoteTree(ref, commit, dir, dest, options);
|
|
460
|
+
copyLocalModule(src, dest);
|
|
461
|
+
return { digest: defaultRemote.contentDigest(dest, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK }) };
|
|
462
|
+
} : undefined;
|
|
463
|
+
return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir, ...(fetch ? { fetch } : {}) });
|
|
464
|
+
}
|
|
465
|
+
/** Copy a materialized module tree (regular files, directories, the CLAUDE.md alias) — nothing else. */
|
|
466
|
+
function copyLocalModule(src, dest, rel = "") {
|
|
467
|
+
mkdirSync(dest, { recursive: true });
|
|
468
|
+
for (const name of readdirSync(src).sort()) {
|
|
469
|
+
const from = join(src, name), to = join(dest, name), at = rel ? `${rel}/${name}` : name, st = lstatSync(from);
|
|
470
|
+
if (st.isSymbolicLink()) {
|
|
471
|
+
if (!defaultRemote.OATS_ALIAS_SYMLINK(at) || readlinkSync(from) !== "AGENTS.md") throw err("E_REMOTE_TREE_UNSAFE", `${from} is a symlink`, { path: from, why: "symlink" });
|
|
472
|
+
symlinkSync("AGENTS.md", to);
|
|
473
|
+
} else if (st.isDirectory()) copyLocalModule(from, to, at);
|
|
474
|
+
else if (st.isFile()) { copyFileSync(from, to); chmodSync(to, (st.mode & 0o111) ? 0o755 : 0o644); }
|
|
475
|
+
else throw err("E_REMOTE_TREE_UNSAFE", `${from} is not a regular file`, { path: from, why: "device" });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The prepared resolution of a CAPABILITY AGENT (a capability's `agents:` soul, e.g.
|
|
481
|
+
* OKF's memory-harvest worker) — lead decision c3 Q1. It composes its providing
|
|
482
|
+
* capability's module and NOTHING else: no workspace defaults, no knowledge,
|
|
483
|
+
* messaging or tasks module, so no provider hook of any slot runs for it, and the
|
|
484
|
+
* providing module's own hooks do not run either (a manifest has no way to declare
|
|
485
|
+
* them for an agent). A knowledge-layer provider's inject is left out: a service
|
|
486
|
+
* agent carries no memory protocol.
|
|
487
|
+
*
|
|
488
|
+
* The module comes from `agent._manifestSource` — the instance home whose module
|
|
489
|
+
* copy declared the agent (the --parent/--relative-to anchor first): its recorded
|
|
490
|
+
* `modules[<cap>]` (copied from that home's verified copy, pinned to the recorded
|
|
491
|
+
* digest) and its recorded `providers[<cap>]` payload. Without such a home (the
|
|
492
|
+
* source instance is gone), `pkg` — the deployment lock's package entry that
|
|
493
|
+
* declares the agent (resolvePackageCapabilityAgent) — supplies the module, and the
|
|
494
|
+
* payload is the manifest defaults ⊕ the workspace's messaging payload (for a
|
|
495
|
+
* messaging-layer provider) ⊕ oats-local.yaml `settings.<cap>`.
|
|
496
|
+
*/
|
|
497
|
+
export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg = null, remoteOptions, catalog = null, remote = defaultRemote } = {}) {
|
|
498
|
+
const found = loadLocal(contextDir);
|
|
499
|
+
const local = found.local;
|
|
500
|
+
const deployment = dirname(found.path);
|
|
501
|
+
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
502
|
+
const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
|
|
503
|
+
const cap = agent.capability;
|
|
504
|
+
let module, payload, origins, localModules = null;
|
|
505
|
+
const anchor = pkg ? null : agent._manifestSource;
|
|
506
|
+
if (anchor) {
|
|
507
|
+
let meta;
|
|
508
|
+
try { meta = JSON.parse(readFileSync(join(anchor, "instance.json"), "utf8")); } catch { meta = null; }
|
|
509
|
+
const recorded = meta?.modules?.[cap];
|
|
510
|
+
const moduleDir = join(anchor, MODULES_DIR, cap);
|
|
511
|
+
if (!recorded?.from || typeof recorded.digest !== "string" || !existsSync(join(moduleDir, "oats.json"))) {
|
|
512
|
+
throw err("E_CAPABILITY_BROKEN", `capability agent ${agent.name}: ${anchor} does not record a verified copy of ${cap} (instance.json modules.${cap} with a digest, and ${join(MODULES_DIR, cap)}); respawn that instance`, { capability: cap, anchor });
|
|
513
|
+
}
|
|
514
|
+
const manifest = JSON.parse(readFileSync(join(moduleDir, "oats.json"), "utf8"));
|
|
515
|
+
module = { name: cap, from: { ...recorded.from }, manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, digest: recorded.digest };
|
|
516
|
+
payload = meta.providers?.[cap] && typeof meta.providers[cap] === "object" ? JSON.parse(JSON.stringify(meta.providers[cap])) : {};
|
|
517
|
+
origins = Object.fromEntries(Object.keys(payload).map((k) => [`/${k.replace(/~/g, "~0").replace(/\//g, "~1")}`, { kind: "anchor", at: `${join(anchor, "instance.json")}#/providers/${cap}` }]));
|
|
518
|
+
localModules = { [cap]: moduleDir };
|
|
519
|
+
} else if (pkg) {
|
|
520
|
+
const manifest = pkg.manifest;
|
|
521
|
+
if (pkg.kind === "member") {
|
|
522
|
+
module = { name: cap, from: { kind: "member", repoKey: pkg.repoKey, commit: pkg.commit }, manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: false, dir: pkg.capDir };
|
|
523
|
+
} else {
|
|
524
|
+
const entry = lock?.packages?.[pkg.package];
|
|
525
|
+
if (!entry) throw err("E_PACKAGE_MISSING", `capability agent ${agent.name}: package ${pkg.package} is not in ${LOCK_FILE}`, { capability: cap, package: pkg.package });
|
|
526
|
+
const ref = packageRef(pkg.package, entry, catalog, remote);
|
|
527
|
+
module = {
|
|
528
|
+
name: cap, from: { kind: "package", package: pkg.package, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
|
|
529
|
+
manifest, layer: SLOTS.includes(manifest.layer) ? manifest.layer : null, private: manifest.private === true, dir: pkg.capDir,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const layers = [{ payload: manifestDefaultsPayload(manifest), origin: { kind: "manifest-default", at: "oats.json#/settings" } }];
|
|
533
|
+
if (module.layer === "messaging" && view?.workspace?.messaging && typeof view.workspace.messaging === "object") {
|
|
534
|
+
const { byTeam, ...base } = view.workspace.messaging;
|
|
535
|
+
layers.push({ payload: base, origin: { kind: "workspace", at: "oats-workspace.yaml#/messaging" } });
|
|
536
|
+
}
|
|
537
|
+
const host = local.settings?.[cap];
|
|
538
|
+
if (host && typeof host === "object") layers.push({ payload: host, origin: { kind: "host", at: `oats-local.yaml#/settings/${cap}` } });
|
|
539
|
+
payload = mergePayload(...layers.map((l) => l.payload));
|
|
540
|
+
origins = payloadOrigins(layers);
|
|
541
|
+
} else {
|
|
542
|
+
throw new TypeError("prepareCapabilityAgent: the agent names no source home, and no package entry was given");
|
|
543
|
+
}
|
|
544
|
+
// The capability's own kernel range, as resolveSoul checks every soul's modules.
|
|
545
|
+
const compat = kernelCompatibility({ ...module.manifest, capability: cap });
|
|
546
|
+
if (!compat.ok) throw err("E_CAPABILITY_INCOMPATIBLE", `capability agent ${agent.name}: ${cap} requires oats ${compat.range}; this kernel is ${compat.kernel}`, { capability: cap, range: compat.range, kernel: compat.kernel, from: module.from });
|
|
547
|
+
for (const { key, value, values } of settingValueProblems(module.manifest, payload)) {
|
|
548
|
+
throw err("E_WORKSPACE_SCHEMA", `${cap}: setting ${JSON.stringify(key)} is ${JSON.stringify(value)}, not one of ${values.map((v) => JSON.stringify(v)).join(", ")}`, { capability: cap, key, value, values, reason: "setting-value" });
|
|
549
|
+
}
|
|
550
|
+
const slots = { knowledge: null, messaging: null, tasks: null };
|
|
551
|
+
if (module.layer) slots[module.layer] = cap;
|
|
552
|
+
if (module.layer === "knowledge") module.inject = false;
|
|
553
|
+
const injects = module.inject !== false && typeof module.manifest.inject === "string" && module.manifest.inject ? [{ module: cap, path: module.manifest.inject }] : [];
|
|
554
|
+
const repoKey = module.from.repoKey ?? `package:${module.from.package}`;
|
|
555
|
+
const soulEntry = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null, capability: cap };
|
|
556
|
+
const soul = { name: agent.name, repoKey, commit: module.from.commit, team: null, path: null };
|
|
557
|
+
const decl = { resolutionApi: RESOLUTION_API, soul, modules: [module], slots, skills: [], injects };
|
|
558
|
+
const payloads = { [cap]: payload };
|
|
559
|
+
const declRevision = revisionOf(decl), payloadRevision = revisionOf(payloads);
|
|
560
|
+
const resolution = { ...decl, payloads, payloadOrigins: { [cap]: origins }, teams: [], declRevision, payloadRevision, revision: revisionOf({ declRevision, payloadRevision }) };
|
|
561
|
+
return { local, deployment, lock: anchor ? null : lock, discovery: view, soulEntry, resolution, remoteOptions, spawn: { providers: {} }, capabilityAgent: true, ...(localModules ? { localModules } : {}) };
|
|
401
562
|
}
|
|
402
563
|
|
|
403
564
|
/** Turn a Resolution's modules (already materialized under `home`) into the
|
|
@@ -417,14 +578,14 @@ export function toCapabilityRows(resolution, home) {
|
|
|
417
578
|
level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
|
|
418
579
|
provenance: [m.from.kind === "package" ? `package ${m.from.package} v${m.from.version}` : `member ${m.from.repoKey} @ ${String(m.from.commit).slice(0, 12)}`],
|
|
419
580
|
settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
|
|
420
|
-
skills, inject: inject && existsSync(inject) ? inject : undefined,
|
|
581
|
+
skills, inject: m.inject !== false && inject && existsSync(inject) ? inject : undefined,
|
|
421
582
|
skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
|
|
422
583
|
// Member capabilities are trusted by membership (decision 2); package
|
|
423
|
-
// capabilities
|
|
424
|
-
//
|
|
584
|
+
// capabilities by their declaration in the workspace's packages: (human
|
|
585
|
+
// decision 2026-09-24: declaring a package is the trust decision).
|
|
425
586
|
hooks: hookCommandsOf(manifest, dir), requiredHooks: requiredHooksOf(manifest),
|
|
426
587
|
environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])],
|
|
427
|
-
missingRequires: [], compatibility: {
|
|
588
|
+
missingRequires: [], compatibility: kernelCompatibility({ ...manifest, capability: m.name }), trust: { trusted: true, reason: m.from.kind === "package" ? "declared workspace package" : "workspace member" },
|
|
428
589
|
executable: !!manifest.commands && Object.keys(manifest.commands).length > 0,
|
|
429
590
|
retirement: manifest.retirement,
|
|
430
591
|
dir, from: m.from, _scope: 0,
|
|
@@ -466,7 +627,7 @@ export function modulesPreview(resolution, agentsRoot, soulName) {
|
|
|
466
627
|
return resolution.modules.map((m) => {
|
|
467
628
|
const prev = previous?.modules?.[m.name];
|
|
468
629
|
const changedSince = !previous ? null : !prev ? { instance: previous.name, was: null } : (prev.commit !== m.from.commit ? { instance: previous.name, was: prev.commit } : false);
|
|
469
|
-
return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, changedSince };
|
|
630
|
+
return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, declares: declaredSettings(m.manifest), changedSince };
|
|
470
631
|
});
|
|
471
632
|
}
|
|
472
633
|
|
|
@@ -479,23 +640,48 @@ export const INSTANCE_MODULES_DIR = MODULES_DIR;
|
|
|
479
640
|
/**
|
|
480
641
|
* Workspace model: a capability-defined agent (a package capability's `agents:`
|
|
481
642
|
* soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
|
|
482
|
-
* not from any instance: every
|
|
643
|
+
* not from any instance: every locked package's capability manifests are read
|
|
483
644
|
* over the remote; the first `agents/<name>` match has its capability tree fetched
|
|
484
645
|
* into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
|
|
485
646
|
* (a per-commit cache shared by every such spawn) so the classic capability-agent
|
|
486
647
|
* machinery can read it exactly as it reads a materialized instance module.
|
|
487
648
|
* → { capability, dir, commit, package, version, manifest, rel } | undefined.
|
|
488
|
-
* Unapproved packages are skipped (they could not have materialized anywhere).
|
|
489
649
|
*/
|
|
490
|
-
export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null } = {}) {
|
|
650
|
+
export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null, discovery = undefined } = {}) {
|
|
491
651
|
if (typeof name !== "string" || !name) return undefined;
|
|
492
652
|
const found = loadLocal(contextDir);
|
|
493
653
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
654
|
+
const remote = defaultRemote;
|
|
655
|
+
const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
|
|
656
|
+
// A confirmed member's own (non-private) capability first: membership is its trust
|
|
657
|
+
// decision, exactly as for a soul's `from: <member>` module.
|
|
658
|
+
for (const row of view?.members || []) {
|
|
659
|
+
if (!row?.confirmed && view?.standalone !== true) continue;
|
|
660
|
+
for (const cap of row.capabilities || []) {
|
|
661
|
+
if (cap.private) continue;
|
|
662
|
+
const agents = Array.isArray(cap.manifest?.agents) ? cap.manifest.agents : [];
|
|
663
|
+
const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
|
|
664
|
+
if (!rel) continue;
|
|
665
|
+
const commit = cap.commit ?? row.commit;
|
|
666
|
+
const store = join(deployment, MODULES_DIR);
|
|
667
|
+
const dir = join(store, `${cap.name}@${String(commit).slice(0, 12)}`);
|
|
668
|
+
if (!existsSync(join(dir, "oats.json"))) {
|
|
669
|
+
mkdirSync(store, { recursive: true });
|
|
670
|
+
await fetchRemoteTree(memberRef(view, remote, cap.repoKey), commit, cap.path, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
671
|
+
}
|
|
672
|
+
return { kind: "member", capability: cap.name, dir, capDir: cap.path, commit, repoKey: cap.repoKey, manifest: cap.manifest, rel };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
494
675
|
if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
|
|
495
676
|
const lock = readLock(deployment);
|
|
496
|
-
|
|
677
|
+
// Declaring a package in packages: is the trust decision: a workspace view
|
|
678
|
+
// admits only locked packages the workspace still declares (a stale lock entry
|
|
679
|
+
// is never a capability-agent source). A standalone view's lock holds only what
|
|
680
|
+
// a standalone sync wrote.
|
|
681
|
+
const declared = view?.standalone === true ? null : (view?.workspace?.packages && typeof view.workspace.packages === "object" ? view.workspace.packages : {});
|
|
497
682
|
for (const [id, entry] of Object.entries(lock.packages || {})) {
|
|
498
|
-
if (!entry || typeof entry !== "object"
|
|
683
|
+
if (!entry || typeof entry !== "object") continue;
|
|
684
|
+
if (declared && !Object.hasOwn(declared, id)) continue;
|
|
499
685
|
let ref;
|
|
500
686
|
try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
|
|
501
687
|
let read;
|
|
@@ -510,7 +696,7 @@ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOp
|
|
|
510
696
|
mkdirSync(store, { recursive: true });
|
|
511
697
|
await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
512
698
|
}
|
|
513
|
-
return { capability: cap.name, dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
|
|
699
|
+
return { kind: "package", capability: cap.name, dir, capDir: cap.dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
|
|
514
700
|
}
|
|
515
701
|
}
|
|
516
702
|
return undefined;
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/** Formatting shared by current preparation and retained instruction projection.
|
|
2
2
|
* Selection/order is supplied by the caller; this module never discovers config. */
|
|
3
|
-
import { readPortableBytes } from "./portable-files.mjs";
|
|
4
|
-
import { oatsError } from "./errors.mjs";
|
|
5
3
|
|
|
6
4
|
export function renderInstructionText(body, blocks) {
|
|
7
5
|
let text = body.replace(/\n*$/, "\n");
|
|
@@ -11,21 +9,3 @@ export function renderInstructionText(body, blocks) {
|
|
|
11
9
|
return text;
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
/** Input is the result of exact record verification, not a current config or
|
|
15
|
-
* a mutable soul directory. All paths come from its verified resource inventory. */
|
|
16
|
-
export function composeCapturedInstructions(verified) {
|
|
17
|
-
const composition = verified.record.dispatch.composition;
|
|
18
|
-
if (!composition) throw oatsError("resolution-incomplete", "captured instruction composition is absent");
|
|
19
|
-
const read = (key) => {
|
|
20
|
-
const file = verified.resources.get(key);
|
|
21
|
-
if (!file) throw oatsError("resolution-incomplete", "captured instruction resource is absent");
|
|
22
|
-
let content;
|
|
23
|
-
try { content = new TextDecoder("utf-8", { fatal: true }).decode(readPortableBytes(file)); }
|
|
24
|
-
catch (cause) { throw oatsError("invalid-resolution", "captured instructions are not readable UTF-8", { cause }); }
|
|
25
|
-
return { file, content };
|
|
26
|
-
};
|
|
27
|
-
const body = read(composition.body);
|
|
28
|
-
const blocks = composition.blocks.map(({ source, resource }) => ({ source, ...read(resource) }));
|
|
29
|
-
const skills = composition.skills.map(({ name, resource }) => ({ name, path: verified.resources.get(resource) }));
|
|
30
|
-
return { text: renderInstructionText(body.content, blocks), blocks, skills, omissions: composition.omissions };
|
|
31
|
-
}
|
package/lib/materialize.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** lib/materialize.mjs — copy whole, compose, record (workspace model v2, module contract §5).
|
|
2
2
|
*
|
|
3
3
|
* Supersedes the module/skill assembly in core.mjs spawn (`prepared` copies, symlinked
|
|
4
|
-
* skill sets, ambient exclusion) and
|
|
4
|
+
* skill sets, ambient exclusion) and the captured path's artifact store.
|
|
5
5
|
*
|
|
6
6
|
* materialize(resolution, home, options)
|
|
7
7
|
* For every module of the resolution: fetch its capability directory whole into
|
|
@@ -240,8 +240,11 @@ 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
|
|
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). */
|
|
244
246
|
function moduleInjects(resolution, module, moduleDir) {
|
|
247
|
+
if (module.inject === false) return [];
|
|
245
248
|
const declared = (resolution.injects || []).filter((i) => i && i.module === module.name);
|
|
246
249
|
const rels = declared.length
|
|
247
250
|
? declared.map((i) => modulePath(i.path, module.name, "inject"))
|
|
@@ -280,7 +283,6 @@ function soulBody(home, options) {
|
|
|
280
283
|
if (typeof options.soulAgentsMd === "string") return { text: options.soulAgentsMd, file: null };
|
|
281
284
|
const candidates = [];
|
|
282
285
|
if (typeof options.soulDir === "string") candidates.push(join(resolve(options.soulDir), "AGENTS.md"));
|
|
283
|
-
candidates.push(join(home, "soul", "AGENTS.md"));
|
|
284
286
|
for (const file of candidates) {
|
|
285
287
|
try { if (statSync(file).isFile()) return { text: readFileSync(file, "utf8"), file }; } catch {}
|
|
286
288
|
}
|
|
@@ -309,7 +311,7 @@ function ensureOatsDir(home) {
|
|
|
309
311
|
* remoteOptions threaded into fetch (cacheDir, exec, …)
|
|
310
312
|
* lock oats-lock.json v3 object: package repo/path/commit for from.kind === "package"
|
|
311
313
|
* catalog { <id>: { url, ref, path } } for lock entries whose source is "catalog:<id>"
|
|
312
|
-
* soulAgentsMd the soul's canonical AGENTS.md text (else read from options.soulDir
|
|
314
|
+
* soulAgentsMd the soul's canonical AGENTS.md text (else read from options.soulDir)
|
|
313
315
|
* blocks pre-rendered blocks [{ source, file, content }] placed BEFORE the capability injects
|
|
314
316
|
* (the kernel's own kernel:… and work-mode:… blocks, composed by core.mjs)
|
|
315
317
|
* now() ISO timestamp source (tests)
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Never "the newest instance's copy" (an instance is not an authority for the
|
|
14
14
|
* deployment) and never an unlocked cache read: trust is exactly spawn's —
|
|
15
|
-
* members by membership, packages by the
|
|
16
|
-
*
|
|
15
|
+
* members by membership, packages by their declaration in the workspace's
|
|
16
|
+
* packages:. A module in the Resolution IS active.
|
|
17
17
|
*
|
|
18
18
|
* `--soul` is required: without a soul there is no Resolution to answer from
|
|
19
19
|
* (E_BAD_ARGS naming --soul). Nothing here reads oats-config.yaml. */
|
|
20
|
-
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
21
|
-
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { oatsError } from "./errors.mjs";
|
|
24
24
|
import { loadLocal } from "./workspace.mjs";
|
|
@@ -58,25 +58,45 @@ export function moduleStoreDir(deployment, module) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** Ensure the module's capability tree is in the deployment store; returns its dir.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* Nothing that differs from the locked commit is ever handed out to run:
|
|
62
|
+
* - a fetch's content digest must match what the fetch read at the commit (and
|
|
63
|
+
* the resolution's `module.digest` when it pins one), else E_PACKAGE_INTEGRITY;
|
|
64
|
+
* the verified digest is recorded beside the tree (`.<cap>@<commit12>.digest`,
|
|
65
|
+
* in the same operator-owned deployment as oats-lock.json);
|
|
66
|
+
* - a tree already there is reused only while its content still digests to that
|
|
67
|
+
* record (the commit in the name does not stop a local edit); a drifted tree,
|
|
68
|
+
* or one with no record, is removed and fetched again.
|
|
69
|
+
* The fetch lands in a sibling staging dir and is renamed into place, so a failed
|
|
70
|
+
* or interrupted fetch never leaves a half tree that a later call would trust. */
|
|
65
71
|
export async function ensureModuleTree(deployment, module, lock, { catalog = null, remote = defaultRemote, remoteOptions, fetch = defaultRemote.fetchRemoteTree } = {}) {
|
|
66
72
|
const store = join(deployment, MODULES_DIR);
|
|
67
73
|
const dir = moduleStoreDir(deployment, module);
|
|
68
|
-
|
|
74
|
+
const record = join(store, `.${basename(dir)}.digest`);
|
|
75
|
+
const digestOf = (tree) => { try { return (remote.contentDigest ?? defaultRemote.contentDigest)(tree, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK }); } catch { return null; } };
|
|
76
|
+
const expected = () => { if (module.digest) return module.digest; try { return readFileSync(record, "utf8").trim() || null; } catch { return null; } };
|
|
77
|
+
const verified = (tree) => { const want = expected(); return !!want && digestOf(tree) === want; };
|
|
78
|
+
if (existsSync(join(dir, "oats.json"))) {
|
|
79
|
+
if (verified(dir)) return dir;
|
|
80
|
+
rmSync(dir, { recursive: true, force: true }); // drifted or unrecorded: re-materialize, never run it
|
|
81
|
+
}
|
|
69
82
|
const ref = moduleRef(module, lock, { catalog, remote });
|
|
70
83
|
if (typeof module.dir !== "string" || !module.dir) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: resolution records no capability directory`, { module: module.name });
|
|
71
84
|
mkdirSync(store, { recursive: true });
|
|
72
85
|
const staging = join(store, `.staging-${module.name}-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
73
86
|
try {
|
|
74
|
-
await fetch(ref, module.from.commit, module.dir, staging, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
87
|
+
const fetched = await fetch(ref, module.from.commit, module.dir, staging, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
75
88
|
if (!existsSync(join(staging, "oats.json"))) throw err("E_CAPABILITY_BROKEN", `module ${module.name}: ${module.dir} at ${String(module.from.commit).slice(0, 12)} has no oats.json`, { module: module.name, dir: module.dir, commit: module.from.commit });
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
const digest = digestOf(staging);
|
|
90
|
+
const reported = fetched && typeof fetched.digest === "string" ? fetched.digest : null;
|
|
91
|
+
if (!digest || (reported && reported !== digest) || (module.digest && module.digest !== digest)) {
|
|
92
|
+
throw err("E_PACKAGE_INTEGRITY", `module ${module.name}: ${module.dir} at ${String(module.from.commit).slice(0, 12)} digests ${digest ?? "unreadably"}, not ${module.digest ?? reported}`,
|
|
93
|
+
{ module: module.name, commit: module.from.commit, expected: module.digest ?? reported, actual: digest, why: "module-store" });
|
|
94
|
+
}
|
|
95
|
+
// A concurrent call may have won; its tree is the same commit — keep it if it matches.
|
|
96
|
+
if (existsSync(join(dir, "oats.json")) && digestOf(dir) === digest) { rmSync(staging, { recursive: true, force: true }); writeFileSync(record, `${digest}\n`); return dir; }
|
|
78
97
|
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); // a half tree from an interrupted direct write
|
|
79
98
|
renameSync(staging, dir);
|
|
99
|
+
writeFileSync(record, `${digest}\n`);
|
|
80
100
|
return dir;
|
|
81
101
|
} catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
|
|
82
102
|
}
|
|
@@ -93,7 +113,7 @@ export async function ensureModuleTree(deployment, module, lock, { catalog = nul
|
|
|
93
113
|
* ensureTree(): Promise<dir> } — or null when no module of the soul's
|
|
94
114
|
* resolution claims the namespace (the caller answers E_UNKNOWN_COMMAND).
|
|
95
115
|
* Two modules claiming one namespace → E_DUPLICATE_NAMESPACE. Everything
|
|
96
|
-
* prepareInstance can throw (E_SOUL_UNKNOWN,
|
|
116
|
+
* prepareInstance can throw (E_SOUL_UNKNOWN,
|
|
97
117
|
* E_REMOTE_UNREADABLE, …) propagates untouched.
|
|
98
118
|
*/
|
|
99
119
|
export async function resolveOperatorDispatch(contextDir, namespace, soulName, { remoteOptions, remote, catalog = null, fetch, prepare = prepareInstance } = {}) {
|