@awebai/oats 0.24.12 → 0.25.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 +936 -2822
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +386 -6
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +233 -0
- package/docs/release-notes/v0.24.13.md +51 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +194 -34
- package/lib/workspace.mjs +635 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** Instance resolution — the bridge from the workspace model (remote → workspace →
|
|
2
|
+
* resolve → materialize) into a spawn.
|
|
3
|
+
*
|
|
4
|
+
* A spawn is: read this machine's `oats-local.yaml` → discover the workspace over
|
|
5
|
+
* its Git remotes in the operator's access context → find the soul among the
|
|
6
|
+
* confirmed members (or external souls) → resolve every capability by `from:`
|
|
7
|
+
* (member = latest state, package = the locked, approved version) → materialize
|
|
8
|
+
* each capability WHOLE into the new home (`.oats/modules/`, `.agents/skills/`) →
|
|
9
|
+
* compose AGENTS.md → launch the harness normally.
|
|
10
|
+
*
|
|
11
|
+
* This module owns the async half (discover + resolve) and the materialize call;
|
|
12
|
+
* `core.mjs#spawnInstance` stays synchronous and consumes a PREPARED resolution
|
|
13
|
+
* (`o.prepared`) produced here. It also turns a Resolution into the capability
|
|
14
|
+
* row shape the rest of the kernel already understands (`toCapabilityRows`), so
|
|
15
|
+
* hooks, environment, requirements and retirement keep working unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here reads `oats-config.yaml`, an installed-capability directory or a
|
|
18
|
+
* per-soul `source:` — those do not exist in this model. */
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
|
|
20
|
+
import { join, resolve as resolvePath, dirname, basename } from "node:path";
|
|
21
|
+
import { oatsError } from "./errors.mjs";
|
|
22
|
+
import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./workspace.mjs";
|
|
23
|
+
import { resolveSoul, packageRef } from "./resolve.mjs";
|
|
24
|
+
import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
25
|
+
import { fetchRemoteTree } from "./remote.mjs";
|
|
26
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
|
|
27
|
+
import { randomBytes } from "node:crypto";
|
|
28
|
+
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
29
|
+
import * as defaultRemote from "./remote.mjs";
|
|
30
|
+
import { parseRepoRef } from "./remote.mjs";
|
|
31
|
+
|
|
32
|
+
function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
|
|
33
|
+
|
|
34
|
+
/** Keys that would poison a payload's prototype (as own keys) or, as a CAPABILITY
|
|
35
|
+
* name, address an inherited member of a plain `{}` (`constructor`, `toString`, …).
|
|
36
|
+
* Same set as lib/resolve.mjs POISON_KEYS; refused with E_WORKSPACE_SCHEMA reason
|
|
37
|
+
* "poison-key" so a `--provider constructor x=1` never reaches Object.prototype. */
|
|
38
|
+
const POISON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
39
|
+
/** `byTeam` is reserved: legal ONLY at the top level of workspace.messaging (decision 23);
|
|
40
|
+
* a spawn payload may never smuggle it in. */
|
|
41
|
+
const RESERVED_KEY = "byTeam";
|
|
42
|
+
|
|
43
|
+
/** Parse repeated `--provider <cap> k=v` occurrences into { <cap>: { k: v } }.
|
|
44
|
+
* Values are strings; `k=v=w` keeps everything after the first `=`. Poison keys
|
|
45
|
+
* (capability OR any path segment) are E_WORKSPACE_SCHEMA reason "poison-key";
|
|
46
|
+
* `byTeam` (any path segment) is E_WORKSPACE_SCHEMA reason "reserved-key". Both are
|
|
47
|
+
* refused again in resolve (defense in depth). The accumulators are built with
|
|
48
|
+
* Object.create(null) and only OWN keys are ever reused, so a name that happens to
|
|
49
|
+
* exist on Object.prototype (`toString`, `hasOwnProperty`) never writes through to it. */
|
|
50
|
+
export function parseProviderFlags(pairs) {
|
|
51
|
+
const out = Object.create(null);
|
|
52
|
+
for (const [cap, kv] of pairs) {
|
|
53
|
+
if (typeof cap !== "string" || !/^[a-z][a-z0-9.-]{0,63}$/i.test(cap)) throw err("E_BAD_ARGS", `--provider needs <capability> <key>=<value> (got capability ${JSON.stringify(cap)})`);
|
|
54
|
+
if (POISON_KEYS.has(cap)) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: capability name ${JSON.stringify(cap)} is refused (it would poison the payload's prototype)`, { path: `/${cap}`, key: cap, reason: "poison-key" });
|
|
55
|
+
if (typeof kv !== "string" || !kv.includes("=")) throw err("E_BAD_ARGS", `--provider ${cap}: expected <key>=<value>, got ${JSON.stringify(kv)}`);
|
|
56
|
+
const i = kv.indexOf("=");
|
|
57
|
+
const key = kv.slice(0, i), value = kv.slice(i + 1);
|
|
58
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(key)) throw err("E_BAD_ARGS", `--provider ${cap}: invalid key ${JSON.stringify(key)}`);
|
|
59
|
+
// dotted keys nest: identity.source=retained:x → { identity: { source: "retained:x" } }
|
|
60
|
+
const parts = key.split(".");
|
|
61
|
+
for (const p of parts) {
|
|
62
|
+
if (POISON_KEYS.has(p)) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (segment ${JSON.stringify(p)} would poison the payload's prototype)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "poison-key" });
|
|
63
|
+
if (p === RESERVED_KEY) throw err("E_WORKSPACE_SCHEMA", `--provider ${cap}: key ${JSON.stringify(key)} is refused (${JSON.stringify(RESERVED_KEY)} is reserved for the top level of workspace.messaging)`, { path: `/${cap}/${parts.join("/")}`, key: p, reason: "reserved-key" });
|
|
64
|
+
}
|
|
65
|
+
if (!Object.hasOwn(out, cap)) out[cap] = Object.create(null);
|
|
66
|
+
let cur = out[cap];
|
|
67
|
+
for (const p of parts.slice(0, -1)) {
|
|
68
|
+
if (!Object.hasOwn(cur, p) || cur[p] === null || typeof cur[p] !== "object") cur[p] = Object.create(null);
|
|
69
|
+
cur = cur[p];
|
|
70
|
+
}
|
|
71
|
+
cur[parts.at(-1)] = value;
|
|
72
|
+
}
|
|
73
|
+
// Hand back ordinary objects (JSON-clean, Object.prototype) now that every key is vetted.
|
|
74
|
+
return JSON.parse(JSON.stringify(out));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Find the soul named `name` in a discovery: confirmed members first (by
|
|
78
|
+
* `repo/name` or bare name when unique), then external souls. */
|
|
79
|
+
export function findSoulEntry(discovery, name) {
|
|
80
|
+
const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
|
|
81
|
+
const hits = [];
|
|
82
|
+
// A standalone view's one row is the repo's own (unconfirmed by definition — the
|
|
83
|
+
// workspace could not be read); resolveSoul admits exactly that case.
|
|
84
|
+
const standaloneOwn = discovery.standalone === true ? discovery.key : null;
|
|
85
|
+
for (const m of discovery.members || []) {
|
|
86
|
+
if (!m.confirmed && m.key !== standaloneOwn) continue;
|
|
87
|
+
for (const s of m.souls || []) {
|
|
88
|
+
if (s.name !== soulPart) continue;
|
|
89
|
+
if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
|
|
90
|
+
hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const x of discovery.external || []) {
|
|
94
|
+
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 });
|
|
95
|
+
}
|
|
96
|
+
if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members or external souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key) });
|
|
97
|
+
if (hits.length > 1) throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} exists in ${hits.length} repos; name it as <repo>/${soulPart}`, { name, repos: hits.map((h) => h.repoKey) });
|
|
98
|
+
return hits[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Materialize a workspace soul's SOURCE (soul.yaml, AGENTS.md, skills/…) into
|
|
102
|
+
* `<agentsRoot>/<name>/soul/` so the classic spawn skeleton (which reads the
|
|
103
|
+
* soul from a directory) can proceed. Idempotent per (repo, commit): a soul dir
|
|
104
|
+
* already at that commit is reused; a different commit replaces it atomically
|
|
105
|
+
* (instances keep their own copies, so this is safe). Returns the soul dir. */
|
|
106
|
+
export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
107
|
+
const e = prepared.soulEntry;
|
|
108
|
+
const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
|
|
109
|
+
const stamp = join(agentDir, ".oats-soul-source.json");
|
|
110
|
+
try { const cur = JSON.parse(readFileSync(stamp, "utf8")); if (cur.repoKey === e.repoKey && cur.commit === e.commit && existsSync(join(soulDir, "soul.yaml"))) return soulDir; } catch { /* absent or stale */ }
|
|
111
|
+
const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
|
|
112
|
+
const staging = join(agentDir, `.soul-staging-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
113
|
+
mkdirSync(agentDir, { recursive: true });
|
|
114
|
+
try {
|
|
115
|
+
// A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
|
|
116
|
+
await fetchRemoteTree(ref, e.commit, e.path, staging, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
|
|
117
|
+
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 });
|
|
118
|
+
if (!existsSync(join(staging, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(staging, "CLAUDE.md"));
|
|
119
|
+
if (existsSync(soulDir)) { const old = `${soulDir}.previous-${Date.now()}`; renameSync(soulDir, old); try { rmSync(old, { recursive: true, force: true }); } catch { /* leave it */ } }
|
|
120
|
+
renameSync(staging, soulDir);
|
|
121
|
+
writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
122
|
+
return soulDir;
|
|
123
|
+
} catch (x) { try { rmSync(staging, { recursive: true, force: true }); } catch { /* nothing */ } throw x; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The async half of a spawn: everything that touches the network. Returns a
|
|
127
|
+
* PREPARED object that `spawnInstance` consumes synchronously. */
|
|
128
|
+
export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
|
|
129
|
+
const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
|
|
130
|
+
const local = found.local;
|
|
131
|
+
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
132
|
+
const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
|
|
133
|
+
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
|
|
134
|
+
const soulEntry = findSoulEntry(discovery, soulName);
|
|
135
|
+
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
136
|
+
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
|
|
140
|
+
* operator's access context cannot see this host" — the ONLY reasons that turn a
|
|
141
|
+
* workspace spawn into the standalone view. `network` / `timeout` are transient: the
|
|
142
|
+
* workspace exists and is ours; a spawn must not quietly degrade to a standalone one. */
|
|
143
|
+
const ACCESS_REASONS = new Set(["auth", "not-found"]);
|
|
144
|
+
const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
|
|
145
|
+
|
|
146
|
+
/** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
|
|
147
|
+
* repo must declare that workspace (oats-membership.yaml). A lone repo with no
|
|
148
|
+
* backlink is not a member of anything and never becomes a capability source. */
|
|
149
|
+
function requireMembership(repo, ref) {
|
|
150
|
+
if (repo.membership) return;
|
|
151
|
+
throw err("E_MEMBERSHIP_UNCONFIRMED", `a standalone view is a MEMBER whose workspace cannot be read; ${repo.key} declares no workspace (no oats-membership.yaml at ${String(repo.commit).slice(0, 12)})`, { key: repo.key, commit: repo.commit, ref, reason: "no-backlink" });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Discover the workspace named by oats-local.yaml — or, when that ref is a REPO
|
|
156
|
+
* whose workspace cannot be read (decision 10, the standalone case: a public
|
|
157
|
+
* member of a privately hosted workspace), fall back to the repo's own souls and
|
|
158
|
+
* capabilities. `standalone: <repo ref>` in oats-local.yaml asks for the standalone
|
|
159
|
+
* view explicitly (no workspace lookup at all) — the repo must still be a member
|
|
160
|
+
* (carry oats-membership.yaml), exactly as on the fallback path.
|
|
161
|
+
*
|
|
162
|
+
* A standalone result carries `standalone: true` and `standaloneReason`:
|
|
163
|
+
* "explicit" — oats-local.yaml said `standalone:`;
|
|
164
|
+
* "unreadable-host" — `workspace:` named a member whose declared host is not
|
|
165
|
+
* readable in this access context (auth / not-found).
|
|
166
|
+
* A transient failure reading the host (network, timeout) is rethrown as-is.
|
|
167
|
+
*/
|
|
168
|
+
export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
|
|
169
|
+
const ro = { remoteOptions, remote };
|
|
170
|
+
if (typeof local.standalone === "string" && local.standalone) {
|
|
171
|
+
const repo = await discoverRepo(local.standalone, ro);
|
|
172
|
+
requireMembership(repo, local.standalone);
|
|
173
|
+
return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
|
|
174
|
+
}
|
|
175
|
+
try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
|
|
176
|
+
catch (e) {
|
|
177
|
+
if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
|
|
178
|
+
// The ref is a repository without oats-workspace.yaml: is it a member whose
|
|
179
|
+
// workspace we cannot read? Then the standalone view is what the operator gets.
|
|
180
|
+
const repo = await discoverRepo(local.workspace, ro);
|
|
181
|
+
if (!repo.membership) throw e;
|
|
182
|
+
try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
|
|
183
|
+
catch (inner) {
|
|
184
|
+
// Only an ACCESS failure on the host means "standalone"; anything else
|
|
185
|
+
// (network, timeout, a broken workspace file) is the caller's to see.
|
|
186
|
+
if (!isAccessFailure(inner)) throw inner;
|
|
187
|
+
return { ...standaloneRepo(local.workspace, repo.commit, repo, { remote }), standaloneReason: "unreadable-host", hostFailure: { code: inner.code, reason: inner.details?.reason ?? inner.provenance?.reason ?? null, url: inner.details?.url ?? inner.provenance?.url ?? null } };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Materialize a prepared resolution into `home`. Called by spawnInstance after
|
|
193
|
+
* the home directory exists and before the harness is launched. */
|
|
194
|
+
export async function materializePrepared(prepared, home) {
|
|
195
|
+
return materialize(prepared.resolution, home, { lock: prepared.lock, remoteOptions: prepared.remoteOptions, soulAgentsMd: prepared.soulAgentsMd, soulDir: prepared.soulDir });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Turn a Resolution's modules (already materialized under `home`) into the
|
|
199
|
+
* capability rows the kernel's hooks/environment/requirements code consumes.
|
|
200
|
+
* Paths point INTO the instance's own copy — never at a repo or a package dir. */
|
|
201
|
+
export function toCapabilityRows(resolution, home) {
|
|
202
|
+
const rows = [];
|
|
203
|
+
for (const m of resolution.modules) {
|
|
204
|
+
const dir = join(home, MODULES_DIR, m.name);
|
|
205
|
+
const manifest = m.manifest;
|
|
206
|
+
const skills = [];
|
|
207
|
+
const skillsRoot = join(home, SKILLS_DIR, m.name);
|
|
208
|
+
if (existsSync(skillsRoot)) for (const e of readdirSync(skillsRoot, { withFileTypes: true })) if (e.isDirectory()) skills.push(join(skillsRoot, e.name));
|
|
209
|
+
const inject = manifest.inject ? join(dir, manifest.inject) : undefined;
|
|
210
|
+
rows.push({
|
|
211
|
+
id: m.name, capability: m.name, manifest, layer: manifest.layer ?? undefined, command: manifest.command,
|
|
212
|
+
level: home, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}`,
|
|
213
|
+
provenance: [m.from.kind === "package" ? `package ${m.from.package} v${m.from.version}` : `member ${m.from.repoKey} @ ${String(m.from.commit).slice(0, 12)}`],
|
|
214
|
+
settings: { ...(resolution.payloads?.[m.name] && typeof resolution.payloads[m.name] === "object" ? resolution.payloads[m.name] : {}) },
|
|
215
|
+
skills, inject: inject && existsSync(inject) ? inject : undefined,
|
|
216
|
+
skillsDeclared: manifest.skills || [], injectDeclared: manifest.inject,
|
|
217
|
+
// Member capabilities are trusted by membership (decision 2); package
|
|
218
|
+
// capabilities were approved per version at sync (E_PACKAGE_UNAPPROVED
|
|
219
|
+
// otherwise, so reaching here means approved).
|
|
220
|
+
hooks: hookCommandsOf(manifest, dir), requiredHooks: requiredHooksOf(manifest),
|
|
221
|
+
environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])],
|
|
222
|
+
missingRequires: [], compatibility: { ok: true }, trust: { trusted: true, reason: m.from.kind === "package" ? "approved package version" : "workspace member" },
|
|
223
|
+
executable: !!manifest.commands && Object.keys(manifest.commands).length > 0,
|
|
224
|
+
retirement: manifest.retirement,
|
|
225
|
+
dir, from: m.from, _scope: 0,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hookCommandsOf(manifest, dir) {
|
|
232
|
+
const out = {};
|
|
233
|
+
const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
|
|
234
|
+
for (const [event, spec] of Object.entries(hooks)) {
|
|
235
|
+
const cmd = typeof spec === "string" ? spec : spec?.command;
|
|
236
|
+
if (typeof cmd === "string" && cmd) out[event] = { command: cmd, cwd: dir, required: spec?.required === true };
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
function requiredHooksOf(manifest) {
|
|
241
|
+
const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
|
|
242
|
+
return Object.entries(hooks).filter(([, s]) => s && typeof s === "object" && s.required === true).map(([e]) => e);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** What a preview shows about modules: from/commit/digest per module and whether
|
|
246
|
+
* it changed since the newest existing instance of the same soul in `agentsRoot`. */
|
|
247
|
+
export function modulesPreview(resolution, agentsRoot, soulName) {
|
|
248
|
+
let previous = null;
|
|
249
|
+
const instancesDir = join(agentsRoot, soulName, "instances");
|
|
250
|
+
if (existsSync(instancesDir)) {
|
|
251
|
+
let newest = null;
|
|
252
|
+
for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
|
|
253
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
254
|
+
try {
|
|
255
|
+
const meta = JSON.parse(readFileSync(join(instancesDir, e.name, "instance.json"), "utf8"));
|
|
256
|
+
if (meta?.modules && (!newest || String(meta.createdAt) > String(newest.createdAt))) newest = { name: e.name, ...meta };
|
|
257
|
+
} catch { /* not an instance */ }
|
|
258
|
+
}
|
|
259
|
+
previous = newest;
|
|
260
|
+
}
|
|
261
|
+
return resolution.modules.map((m) => {
|
|
262
|
+
const prev = previous?.modules?.[m.name];
|
|
263
|
+
const changedSince = !previous ? null : !prev ? { instance: previous.name, was: null } : (prev.commit !== m.from.commit ? { instance: previous.name, was: prev.commit } : false);
|
|
264
|
+
return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, changedSince };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The relative pi/claude/codex skill directory inside a home — exported so the
|
|
269
|
+
* launch recipe and tests agree on it. */
|
|
270
|
+
export const INSTANCE_SKILLS_DIR = SKILLS_DIR;
|
|
271
|
+
export const INSTANCE_MODULES_DIR = MODULES_DIR;
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Workspace model: a capability-defined agent (a package capability's `agents:`
|
|
276
|
+
* soul — OKF's memory-harvest worker, say) resolves from the deployment's LOCK,
|
|
277
|
+
* not from any instance: every approved package's capability manifests are read
|
|
278
|
+
* over the remote; the first `agents/<name>` match has its capability tree fetched
|
|
279
|
+
* into the deployment's module store `<deployment>/.oats/modules/<cap>@<commit>/`
|
|
280
|
+
* (a per-commit cache shared by every such spawn) so the classic capability-agent
|
|
281
|
+
* machinery can read it exactly as it reads a materialized instance module.
|
|
282
|
+
* → { capability, dir, commit, package, version, manifest, rel } | undefined.
|
|
283
|
+
* Unapproved packages are skipped (they could not have materialized anywhere).
|
|
284
|
+
*/
|
|
285
|
+
export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOptions, catalog = null } = {}) {
|
|
286
|
+
if (typeof name !== "string" || !name) return undefined;
|
|
287
|
+
const found = loadLocal(contextDir);
|
|
288
|
+
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
289
|
+
if (!existsSync(join(deployment, LOCK_FILE))) return undefined;
|
|
290
|
+
const lock = readLock(deployment);
|
|
291
|
+
const remote = defaultRemote;
|
|
292
|
+
for (const [id, entry] of Object.entries(lock.packages || {})) {
|
|
293
|
+
if (!entry || typeof entry !== "object" || !entry.approved || typeof entry.approved.executables !== "string") continue;
|
|
294
|
+
let ref;
|
|
295
|
+
try { ref = packageRef(id, entry, catalog, remote); } catch { continue; }
|
|
296
|
+
let read;
|
|
297
|
+
try { read = await readPackageManifests(remote, ref, entry.commit, entry.path, { package: id }); } catch { continue; }
|
|
298
|
+
for (const cap of read.capabilities) {
|
|
299
|
+
const agents = Array.isArray(cap.manifest.agents) ? cap.manifest.agents : [];
|
|
300
|
+
const rel = agents.find((a) => typeof a === "string" && basename(a) === name);
|
|
301
|
+
if (!rel) continue;
|
|
302
|
+
const store = join(deployment, MODULES_DIR);
|
|
303
|
+
const dir = join(store, `${cap.name}@${String(entry.commit).slice(0, 12)}`);
|
|
304
|
+
if (!existsSync(join(dir, "oats.json"))) {
|
|
305
|
+
mkdirSync(store, { recursive: true });
|
|
306
|
+
await fetchRemoteTree(ref, entry.commit, cap.dir, dir, { ...(remoteOptions || {}), allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
|
|
307
|
+
}
|
|
308
|
+
return { capability: cap.name, dir, commit: entry.commit, package: id, version: entry.version, manifest: cap.manifest, rel };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|