@awebai/oats 0.24.13 → 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 +930 -2820
- 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 +324 -5
- 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.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 +90 -16
- 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,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/workspace.mjs — declarations, membership, discovery (module contract §2).
|
|
3
|
+
*
|
|
4
|
+
* Reads the four v2 declaration files (oats-workspace.yaml, oats-membership.yaml,
|
|
5
|
+
* soul.yaml, oats-local.yaml) over Git remotes through lib/remote.mjs, validates
|
|
6
|
+
* them against docs/*.schema.json with a small in-module validator (no ajv at
|
|
7
|
+
* runtime), observes the reciprocal membership handshake in the operator's own
|
|
8
|
+
* access context, and enumerates every member's souls and capabilities.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here shells out. Every remote access goes through the `remote`
|
|
11
|
+
* option (defaults to lib/remote.mjs) so tests can inject an in-memory remote.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { parseConfigData } from "./config-data.mjs";
|
|
16
|
+
import { oatsError } from "./errors.mjs";
|
|
17
|
+
import * as defaultRemote from "./remote.mjs";
|
|
18
|
+
import { bindRemote, classifyPackageValue } from "./packages.mjs";
|
|
19
|
+
|
|
20
|
+
/* ───────────────────────────── errors ─────────────────────────────────── */
|
|
21
|
+
|
|
22
|
+
/** oatsError with `details` readable as both e.provenance (today) and e.details. */
|
|
23
|
+
function fail(code, message, details) {
|
|
24
|
+
const e = oatsError(code, message, details);
|
|
25
|
+
if (details !== undefined) e.details = details;
|
|
26
|
+
return e;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ───────────────────────────── remote access ──────────────────────────── */
|
|
30
|
+
|
|
31
|
+
// lib/remote.mjs is the default remote (contract §1); callers may inject `{ remote }` (tests use an
|
|
32
|
+
// in-memory fake) and thread `{ remoteOptions }` (cacheDir, exec, …) into every remote call.
|
|
33
|
+
function remoteOf(options) {
|
|
34
|
+
const remote = assertRemoteApi(options?.remote ?? defaultRemote);
|
|
35
|
+
return bindRemote(remote, options?.remoteOptions);
|
|
36
|
+
}
|
|
37
|
+
const REQUIRED_REMOTE_API = ["parseRepoRef", "observeRemote", "readRemoteFile", "listRemoteTree"];
|
|
38
|
+
function assertRemoteApi(remote) {
|
|
39
|
+
for (const name of REQUIRED_REMOTE_API) {
|
|
40
|
+
if (typeof remote?.[name] !== "function") throw new TypeError(`remote must provide ${name}() (module contract §1)`);
|
|
41
|
+
}
|
|
42
|
+
return remote;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ───────────────────────────── schemas ────────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
const SCHEMA_FILES = {
|
|
48
|
+
workspace: "oats-workspace.schema.json",
|
|
49
|
+
membership: "oats-membership.schema.json",
|
|
50
|
+
soul: "soul.schema.json",
|
|
51
|
+
local: "oats-local.schema.json",
|
|
52
|
+
};
|
|
53
|
+
const schemaCache = new Map();
|
|
54
|
+
function schemaFor(kind) {
|
|
55
|
+
if (!schemaCache.has(kind)) {
|
|
56
|
+
const file = SCHEMA_FILES[kind];
|
|
57
|
+
if (!file) throw new TypeError(`unknown schema kind ${kind}`);
|
|
58
|
+
schemaCache.set(kind, JSON.parse(readFileSync(new URL(`../docs/${file}`, import.meta.url), "utf8")));
|
|
59
|
+
}
|
|
60
|
+
return schemaCache.get(kind);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
64
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
65
|
+
const typeOf = (v) => (v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v);
|
|
66
|
+
const show = (v) => (v === undefined ? "undefined" : JSON.stringify(v));
|
|
67
|
+
const regexCache = new Map();
|
|
68
|
+
function regexOf(pattern) {
|
|
69
|
+
if (!regexCache.has(pattern)) regexCache.set(pattern, new RegExp(pattern, "u"));
|
|
70
|
+
return regexCache.get(pattern);
|
|
71
|
+
}
|
|
72
|
+
function deref(root, ref) {
|
|
73
|
+
if (!ref.startsWith("#/")) throw new TypeError(`only local $ref is supported: ${ref}`);
|
|
74
|
+
let node = root;
|
|
75
|
+
for (const part of ref.slice(2).split("/")) {
|
|
76
|
+
node = node?.[part.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
77
|
+
if (node === undefined) throw new TypeError(`unresolvable $ref ${ref}`);
|
|
78
|
+
}
|
|
79
|
+
return node;
|
|
80
|
+
}
|
|
81
|
+
function typeMatches(value, type) {
|
|
82
|
+
const actual = typeOf(value);
|
|
83
|
+
if (type === "number") return actual === "number" || actual === "integer";
|
|
84
|
+
return actual === type;
|
|
85
|
+
}
|
|
86
|
+
function describe(schema) {
|
|
87
|
+
if (schema.const !== undefined) return show(schema.const);
|
|
88
|
+
if (schema.enum) return schema.enum.map(show).join(" | ");
|
|
89
|
+
if (schema.$ref) return schema.$ref.replace(/^#\/\$defs\//, "");
|
|
90
|
+
if (schema.type) return Array.isArray(schema.type) ? schema.type.join(" | ") : schema.type;
|
|
91
|
+
return "value";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Small draft-07 subset validator: returns [{ path, message }] (empty = valid). */
|
|
95
|
+
export function validateAgainst(schema, value, { root = schema, path = "" } = {}) {
|
|
96
|
+
const problems = [];
|
|
97
|
+
const push = (p, message) => problems.push({ path: p, message });
|
|
98
|
+
const check = (s, v, p) => {
|
|
99
|
+
if (s.$ref) s = { ...deref(root, s.$ref), ...Object.fromEntries(Object.entries(s).filter(([k]) => k !== "$ref" && k !== "description")) };
|
|
100
|
+
if (s.type !== undefined) {
|
|
101
|
+
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
102
|
+
if (!types.some((t) => typeMatches(v, t))) { push(p, `expected ${types.join(" | ")}, got ${typeOf(v)}`); return; }
|
|
103
|
+
}
|
|
104
|
+
if (s.const !== undefined && JSON.stringify(v) !== JSON.stringify(s.const)) { push(p, `expected ${show(s.const)}, got ${show(v)}`); return; }
|
|
105
|
+
if (s.enum && !s.enum.some((e) => JSON.stringify(e) === JSON.stringify(v))) { push(p, `expected one of ${s.enum.map(show).join(", ")}, got ${show(v)}`); return; }
|
|
106
|
+
if (s.anyOf || s.oneOf) {
|
|
107
|
+
const branches = s.anyOf || s.oneOf;
|
|
108
|
+
const results = branches.map((b) => validateAgainst(b, v, { root, path: p }));
|
|
109
|
+
const passing = results.filter((r) => r.length === 0).length;
|
|
110
|
+
if (passing === 0) {
|
|
111
|
+
// Prefer the branch whose shape matches the value's type; else summarize.
|
|
112
|
+
const resolved = branches.map((b) => (b.$ref ? deref(root, b.$ref) : b));
|
|
113
|
+
const byType = resolved.findIndex((b) => b.type && typeMatches(v, b.type));
|
|
114
|
+
if (byType >= 0 && results[byType].length) problems.push(...results[byType]);
|
|
115
|
+
else push(p, `expected ${resolved.map(describe).join(" | ")}, got ${show(v)}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (s.oneOf && passing > 1) { push(p, `matches ${passing} alternatives, expected exactly one`); return; }
|
|
119
|
+
}
|
|
120
|
+
if (typeof v === "string") {
|
|
121
|
+
if (s.minLength !== undefined && v.length < s.minLength) push(p, `must be at least ${s.minLength} character(s)`);
|
|
122
|
+
if (s.maxLength !== undefined && v.length > s.maxLength) push(p, `must be at most ${s.maxLength} character(s)`);
|
|
123
|
+
if (s.pattern && !regexOf(s.pattern).test(v)) push(p, `${show(v)} does not match ${s.pattern}`);
|
|
124
|
+
} else if (typeof v === "number") {
|
|
125
|
+
if (s.minimum !== undefined && v < s.minimum) push(p, `must be >= ${s.minimum}`);
|
|
126
|
+
if (s.maximum !== undefined && v > s.maximum) push(p, `must be <= ${s.maximum}`);
|
|
127
|
+
} else if (Array.isArray(v)) {
|
|
128
|
+
if (s.minItems !== undefined && v.length < s.minItems) push(p, `must have at least ${s.minItems} item(s)`);
|
|
129
|
+
if (s.maxItems !== undefined && v.length > s.maxItems) push(p, `must have at most ${s.maxItems} item(s)`);
|
|
130
|
+
if (s.uniqueItems) {
|
|
131
|
+
const seen = new Map();
|
|
132
|
+
v.forEach((item, i) => { const k = JSON.stringify(item); if (seen.has(k)) push(`${p}/${i}`, `duplicates item ${seen.get(k)}`); else seen.set(k, i); });
|
|
133
|
+
}
|
|
134
|
+
if (s.items) v.forEach((item, i) => check(s.items, item, `${p}/${i}`));
|
|
135
|
+
} else if (isObject(v)) {
|
|
136
|
+
const keys = Object.keys(v);
|
|
137
|
+
if (s.minProperties !== undefined && keys.length < s.minProperties) push(p, `must have at least ${s.minProperties} propert${s.minProperties === 1 ? "y" : "ies"}`);
|
|
138
|
+
if (s.maxProperties !== undefined && keys.length > s.maxProperties) push(p, `must have at most ${s.maxProperties} propert${s.maxProperties === 1 ? "y" : "ies"} (got ${keys.join(", ")})`);
|
|
139
|
+
for (const req of s.required || []) if (!Object.hasOwn(v, req)) push(p, `missing required property ${show(req)}`);
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const kp = `${p}/${pointerKey(key)}`;
|
|
142
|
+
if (s.propertyNames) {
|
|
143
|
+
for (const pr of validateAgainst(s.propertyNames, key, { root, path: kp })) push(kp, `invalid key ${show(key)}: ${pr.message}`);
|
|
144
|
+
}
|
|
145
|
+
let matched = false;
|
|
146
|
+
if (s.properties && Object.hasOwn(s.properties, key)) { matched = true; check(s.properties[key], v[key], kp); }
|
|
147
|
+
if (s.patternProperties) for (const [pat, sub] of Object.entries(s.patternProperties)) if (regexOf(pat).test(key)) { matched = true; check(sub, v[key], kp); }
|
|
148
|
+
if (!matched) {
|
|
149
|
+
if (s.additionalProperties === false) push(kp, `unknown property ${show(key)}`);
|
|
150
|
+
else if (isObject(s.additionalProperties)) check(s.additionalProperties, v[key], kp);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
check(schema, value, path);
|
|
156
|
+
return problems;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ───────────────────────────── domain rules ───────────────────────────── */
|
|
160
|
+
|
|
161
|
+
const ABSOLUTE_PATH = /^(?:\/|\\\\|[A-Za-z]:[\\/])/;
|
|
162
|
+
function* strings(value, path = "") {
|
|
163
|
+
if (typeof value === "string") yield [path, value];
|
|
164
|
+
else if (Array.isArray(value)) for (let i = 0; i < value.length; i++) yield* strings(value[i], `${path}/${i}`);
|
|
165
|
+
else if (isObject(value)) for (const [k, v] of Object.entries(value)) yield* strings(v, `${path}/${pointerKey(k)}`);
|
|
166
|
+
}
|
|
167
|
+
function refKey(remote, ref) {
|
|
168
|
+
return remote.parseRepoRef(ref).key;
|
|
169
|
+
}
|
|
170
|
+
/** Push a problem for a ref that parseRepoRef refuses; returns the key or null. */
|
|
171
|
+
function keyOrProblem(remote, ref, path, problems) {
|
|
172
|
+
try { return refKey(remote, ref); }
|
|
173
|
+
catch (e) {
|
|
174
|
+
if (e?.code === "E_REPO_REF") { problems.push({ path, message: `not a repo ref: ${e.message}` }); return null; }
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Schema + domain problems for an oats-workspace.yaml value. Refs are parsed with `remote`
|
|
180
|
+
* (default lib/remote.mjs) so duplicate members and malformed refs are always caught. */
|
|
181
|
+
export function validateWorkspace(value, { remote = defaultRemote } = {}) {
|
|
182
|
+
const problems = validateAgainst(schemaFor("workspace"), value);
|
|
183
|
+
if (!isObject(value)) return problems;
|
|
184
|
+
if (value.schemaVersion !== 2) return problems;
|
|
185
|
+
for (const [path, s] of strings(value)) {
|
|
186
|
+
if (ABSOLUTE_PATH.test(s)) problems.push({ path, message: `absolute paths are refused in the workspace file (${show(s)}); host paths belong in oats-local.yaml` });
|
|
187
|
+
}
|
|
188
|
+
// packages: exactly two value forms (contract §2 non-collapse rule) — a catalog version or git:<repo>@<ref>.
|
|
189
|
+
if (isObject(value.packages)) for (const [id, v] of Object.entries(value.packages)) {
|
|
190
|
+
const c = classifyPackageValue(v);
|
|
191
|
+
if (c.problem) { problems.push({ path: `/packages/${pointerKey(id)}`, message: `${c.problem} (a package is a bare version like v2.1.3 or git:<repo>@<ref>)` }); continue; }
|
|
192
|
+
if (c.kind === "git") keyOrProblem(remote, c.repo, `/packages/${pointerKey(id)}`, problems);
|
|
193
|
+
}
|
|
194
|
+
const teams = isObject(value.teams) ? Object.keys(value.teams) : [];
|
|
195
|
+
if (Array.isArray(value.members)) {
|
|
196
|
+
const seen = new Map();
|
|
197
|
+
value.members.forEach((ref, i) => {
|
|
198
|
+
if (typeof ref !== "string") return;
|
|
199
|
+
const key = keyOrProblem(remote, ref, `/members/${i}`, problems);
|
|
200
|
+
if (key === null) return;
|
|
201
|
+
if (seen.has(key)) problems.push({ path: `/members/${i}`, message: `duplicates member ${seen.get(key)} (${key})` });
|
|
202
|
+
else seen.set(key, i);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (isObject(value.stores)) for (const [name, ref] of Object.entries(value.stores)) if (typeof ref === "string") keyOrProblem(remote, ref, `/stores/${pointerKey(name)}`, problems);
|
|
206
|
+
// Decision 23: messaging.byTeam addresses provider payload by team label — every label must be declared.
|
|
207
|
+
// `byTeam` is legal HERE only; nested inside a team's payload it would be a second, unmerged byTeam (M9).
|
|
208
|
+
if (isObject(value.messaging) && value.messaging.byTeam !== undefined) {
|
|
209
|
+
if (!isObject(value.messaging.byTeam)) problems.push({ path: "/messaging/byTeam", message: "must be an object of { <team label>: <payload> }" });
|
|
210
|
+
else for (const label of Object.keys(value.messaging.byTeam)) {
|
|
211
|
+
if (!teams.includes(label)) problems.push({ path: `/messaging/byTeam/${pointerKey(label)}`, message: `team ${show(label)} is not declared in teams` });
|
|
212
|
+
if (!isObject(value.messaging.byTeam[label])) problems.push({ path: `/messaging/byTeam/${pointerKey(label)}`, message: "must be an object (the payload for that team)" });
|
|
213
|
+
else reservedKeyProblems(value.messaging.byTeam[label], `/messaging/byTeam/${pointerKey(label)}`, problems);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(value.external)) value.external.forEach((entry, i) => {
|
|
217
|
+
if (!isObject(entry)) return;
|
|
218
|
+
if (typeof entry.source === "string") {
|
|
219
|
+
const at = entry.source.lastIndexOf("@");
|
|
220
|
+
if (at > 0) keyOrProblem(remote, entry.source.slice(0, at), `/external/${i}/source`, problems);
|
|
221
|
+
}
|
|
222
|
+
if (typeof entry.team === "string" && !teams.includes(entry.team)) problems.push({ path: `/external/${i}/team`, message: `team ${show(entry.team)} is not declared in teams` });
|
|
223
|
+
});
|
|
224
|
+
if (isObject(value.defaults)) {
|
|
225
|
+
const d = value.defaults;
|
|
226
|
+
for (const slot of ["knowledge", "messaging", "tasks", "capabilities"]) fromProblems(remote, d[slot], `/defaults/${slot}`, problems, { here: false });
|
|
227
|
+
if (isObject(d.byTeam)) for (const label of Object.keys(d.byTeam)) {
|
|
228
|
+
if (!teams.includes(label)) problems.push({ path: `/defaults/byTeam/${pointerKey(label)}`, message: `team ${show(label)} is not declared in teams` });
|
|
229
|
+
fromProblems(remote, d.byTeam[label]?.capabilities, `/defaults/byTeam/${pointerKey(label)}/capabilities`, problems, { here: false });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return problems;
|
|
233
|
+
}
|
|
234
|
+
export function validateMembership(value) { return validateAgainst(schemaFor("membership"), value); }
|
|
235
|
+
/** Schema + domain problems for a soul.yaml value: `from:` values must be canonical (see fromProblems);
|
|
236
|
+
* slot payloads may not carry the reserved `byTeam` key (see reservedKeyProblems). */
|
|
237
|
+
export function validateSoul(value, { remote = defaultRemote } = {}) {
|
|
238
|
+
const problems = validateAgainst(schemaFor("soul"), value);
|
|
239
|
+
if (isObject(value) && value.schemaVersion === 2) {
|
|
240
|
+
fromProblems(remote, value.capabilities, "/capabilities", problems, { here: true });
|
|
241
|
+
for (const slot of ["knowledge", "messaging", "tasks"]) if (isObject(value[slot])) reservedKeyProblems(value[slot], `/${slot}`, problems);
|
|
242
|
+
}
|
|
243
|
+
return problems;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `byTeam` is RESERVED (decision 23): it addresses a per-team payload and is legal only at the top level
|
|
247
|
+
* of workspace.messaging, where resolve merges base ⊕ byTeam[soul.team] and strips it. Anywhere else — a
|
|
248
|
+
* soul's slot payload, oats-local.yaml settings[cap], a spawn provider, or nested inside a team's own
|
|
249
|
+
* payload — it would reach the provider verbatim (or shadow the real one); refused as a schema problem
|
|
250
|
+
* with `reason: "reserved-key"`. Only the payload's TOP level is checked: deeper keys are the provider's. */
|
|
251
|
+
const RESERVED_PAYLOAD_KEY = "byTeam";
|
|
252
|
+
function reservedKeyProblems(payload, path, problems) {
|
|
253
|
+
if (!isObject(payload)) return;
|
|
254
|
+
if (Object.hasOwn(payload, RESERVED_PAYLOAD_KEY)) problems.push({ path: `${path}/${RESERVED_PAYLOAD_KEY}`, reason: "reserved-key", message: `${show(RESERVED_PAYLOAD_KEY)} is reserved: it is legal only at the top level of the workspace file's messaging: payload (decision 23)` });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `from:` must be exactly what the resolver compares against — "package", "here" (souls only) or a CANONICAL
|
|
258
|
+
* repo key (`parseRepoRef(ref).key`: lowercase host, no scheme, no `git:`, no `.git`, or `local/<abs>`).
|
|
259
|
+
* A key spelled otherwise (git:…, https://…, uppercase host) would pass the schema and fail at spawn with a
|
|
260
|
+
* confusing E_NOT_A_MEMBER; it is a schema problem here instead. */
|
|
261
|
+
function fromProblems(remote, capabilities, path, problems, { here }) {
|
|
262
|
+
if (!isObject(capabilities)) return;
|
|
263
|
+
for (const [name, choice] of Object.entries(capabilities)) {
|
|
264
|
+
if (!isObject(choice) || typeof choice.from !== "string") continue;
|
|
265
|
+
const from = choice.from, at = `${path}/${pointerKey(name)}/from`;
|
|
266
|
+
if (from === "package") continue;
|
|
267
|
+
if (from === "here") { if (!here) problems.push({ path: at, message: `"here" is only meaningful in a soul; a workspace default names a repo key or package` }); continue; }
|
|
268
|
+
let key = null;
|
|
269
|
+
try { key = remote.parseRepoRef(from.startsWith("local/") ? from.slice("local/".length) : `git:${from}`).key; } catch {}
|
|
270
|
+
if (key !== from) problems.push({ path: at, message: `${show(from)} is not a canonical repo key (expected <host>/<path> as parseRepoRef(...).key${key ? `, e.g. ${show(key)}` : ""}); \`here\`, \`package\` or a key are the only forms` });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Schema + domain problems for an oats-local.yaml value: settings[<cap>] may not carry the reserved `byTeam` key. */
|
|
274
|
+
export function validateLocal(value) {
|
|
275
|
+
const problems = validateAgainst(schemaFor("local"), value);
|
|
276
|
+
if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
|
|
277
|
+
return problems;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ───────────────────────────── file decoding ──────────────────────────── */
|
|
281
|
+
|
|
282
|
+
const FILE_KINDS = {
|
|
283
|
+
workspace: { file: "oats-workspace.yaml", validate: validateWorkspace },
|
|
284
|
+
membership: { file: "oats-membership.yaml", validate: validateMembership },
|
|
285
|
+
soul: { file: "soul.yaml", validate: validateSoul },
|
|
286
|
+
local: { file: "oats-local.yaml", validate: validateLocal },
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Decode YAML/JSON bytes into plain data; syntax errors become one problem at "". */
|
|
290
|
+
function decodeDocument(bytes, origin) {
|
|
291
|
+
try { return { value: parseConfigData(bytes, { origin }) .value }; }
|
|
292
|
+
catch (e) { return { problems: [{ path: "", message: `cannot decode ${origin.path}: ${e.message}` }] }; }
|
|
293
|
+
}
|
|
294
|
+
function schemaHint(kind, value) {
|
|
295
|
+
if (isObject(value) && value.schemaVersion !== 2) {
|
|
296
|
+
return ` (this kernel reads ${FILE_KINDS[kind].file} schemaVersion 2 only; found ${show(value.schemaVersion)})`;
|
|
297
|
+
}
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
/** Parse + validate a declaration file → { value } | { problems }. Never throws. */
|
|
301
|
+
function readDeclaration(kind, bytes, origin, validateOptions) {
|
|
302
|
+
const decoded = decodeDocument(bytes, origin);
|
|
303
|
+
if (decoded.problems) return decoded;
|
|
304
|
+
const problems = FILE_KINDS[kind].validate(decoded.value, validateOptions);
|
|
305
|
+
if (problems.length) return { problems, value: decoded.value };
|
|
306
|
+
return { value: decoded.value };
|
|
307
|
+
}
|
|
308
|
+
function schemaError(kind, origin, problems, value) {
|
|
309
|
+
const where = origin.repoKey ? `${origin.repoKey}@${(origin.commit || "").slice(0, 12)}:${origin.path}` : origin.path;
|
|
310
|
+
// A single-cause refusal (reserved-key, …) surfaces its reason on details for callers that branch on it.
|
|
311
|
+
const reasons = new Set(problems.map((p) => p.reason).filter(Boolean));
|
|
312
|
+
return fail("E_WORKSPACE_SCHEMA", `${FILE_KINDS[kind].file} at ${where} is invalid${schemaHint(kind, value)}: ${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, {
|
|
313
|
+
path: origin.path, repoKey: origin.repoKey, commit: origin.commit, problems, ...(reasons.size === 1 ? { reason: [...reasons][0] } : {}),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ───────────────────────────── public API ─────────────────────────────── */
|
|
318
|
+
|
|
319
|
+
/** Walk up from dir to find oats-local.yaml → { path, local } | E_LOCAL_MISSING. */
|
|
320
|
+
export function loadLocal(dir) {
|
|
321
|
+
let current = resolve(dir);
|
|
322
|
+
const visited = [];
|
|
323
|
+
for (;;) {
|
|
324
|
+
const candidate = join(current, "oats-local.yaml");
|
|
325
|
+
visited.push(candidate);
|
|
326
|
+
if (existsSync(candidate)) {
|
|
327
|
+
const origin = { kind: "local", path: candidate };
|
|
328
|
+
const read = readDeclaration("local", readFileSync(candidate), origin);
|
|
329
|
+
if (read.problems) throw schemaError("local", origin, read.problems, read.value);
|
|
330
|
+
return { path: candidate, local: read.value };
|
|
331
|
+
}
|
|
332
|
+
const parent = dirname(current);
|
|
333
|
+
if (parent === current) break;
|
|
334
|
+
current = parent;
|
|
335
|
+
}
|
|
336
|
+
throw fail("E_LOCAL_MISSING", `no oats-local.yaml found walking up from ${resolve(dir)}`, { dir: resolve(dir), searched: visited });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Observe the workspace host: → { key, url, commit, workspace, observedAt }. */
|
|
340
|
+
export async function observeWorkspace(ref, { at, remote: injected, remoteOptions } = {}) {
|
|
341
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
342
|
+
const obs = await remote.observeRemote(ref, { at });
|
|
343
|
+
const path = "oats-workspace.yaml";
|
|
344
|
+
const origin = { kind: "workspace", repoKey: obs.key, commit: obs.commit, path };
|
|
345
|
+
let bytes;
|
|
346
|
+
try { ({ bytes } = await remote.readRemoteFile(ref, obs.commit, path)); }
|
|
347
|
+
catch (e) {
|
|
348
|
+
// Contract: observeWorkspace throws E_REMOTE_UNREADABLE | E_WORKSPACE_SCHEMA. A repo without the
|
|
349
|
+
// file is not a workspace (the usual mistake: a member handed in as the workspace).
|
|
350
|
+
if (e?.code === "E_REMOTE_PATH_MISSING") throw fail("E_WORKSPACE_SCHEMA", `${obs.key}@${obs.commit.slice(0, 12)} has no ${path}: it is not a workspace host`, { path, notAHost: true, repoKey: obs.key, commit: obs.commit, problems: [{ path: "", message: `${path} is missing` }], cause: e.code });
|
|
351
|
+
throw e;
|
|
352
|
+
}
|
|
353
|
+
const read = readDeclaration("workspace", bytes, origin, { remote });
|
|
354
|
+
if (read.problems) throw schemaError("workspace", origin, read.problems, read.value);
|
|
355
|
+
return { key: obs.key, url: obs.url, ref, commit: obs.commit, workspace: read.value, observedAt: obs.observedAt };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const unconfirmed = (key, reason, detail, extra = {}) => ({ key, confirmed: false, reason, detail, ...extra });
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Read the member's oats-membership.yaml at its default branch in the same access context.
|
|
362
|
+
* → { key, commit, confirmed: true, team } | { key, confirmed: false, reason, detail }
|
|
363
|
+
*/
|
|
364
|
+
export async function confirmMembership(workspaceObs, memberRef, { remote: injected, remoteOptions } = {}) {
|
|
365
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
366
|
+
const workspace = workspaceObs.workspace;
|
|
367
|
+
const wsProblems = validateWorkspace(workspace, { remote });
|
|
368
|
+
if (wsProblems.length) throw schemaError("workspace", { kind: "workspace", repoKey: workspaceObs.key, commit: workspaceObs.commit, path: "oats-workspace.yaml" }, wsProblems, workspace);
|
|
369
|
+
let key;
|
|
370
|
+
try { key = refKey(remote, memberRef); }
|
|
371
|
+
catch (e) {
|
|
372
|
+
if (e?.code === "E_REPO_REF") return unconfirmed(typeof memberRef === "string" ? memberRef : String(memberRef), "not-listed", `${show(memberRef)} is not a repo ref: ${e.message}`);
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
const listed = (workspace.members || []).some((m) => refKey(remote, m) === key);
|
|
376
|
+
if (!listed) return unconfirmed(key, "not-listed", `${key} is not in members of workspace ${workspaceObs.key}`);
|
|
377
|
+
const cannotRead = (e) => unconfirmed(key, "cannot-read", `cannot read ${e.details?.url ?? e.provenance?.url ?? memberRef}${e.details?.reason ? ` (${e.details.reason})` : ""}`, { url: e.details?.url ?? e.provenance?.url ?? null });
|
|
378
|
+
let obs;
|
|
379
|
+
try { obs = await remote.observeRemote(memberRef); }
|
|
380
|
+
catch (e) {
|
|
381
|
+
if (e?.code === "E_REMOTE_UNREADABLE") return cannotRead(e);
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
let bytes;
|
|
385
|
+
try { ({ bytes } = await remote.readRemoteFile(memberRef, obs.commit, "oats-membership.yaml")); }
|
|
386
|
+
catch (e) {
|
|
387
|
+
if (e?.code === "E_REMOTE_PATH_MISSING") return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} has no oats-membership.yaml`, { commit: obs.commit });
|
|
388
|
+
if (e?.code === "E_REMOTE_UNREADABLE") return cannotRead(e);
|
|
389
|
+
// Contract: NEVER throws for an unconfirmed member. Anything else the remote refuses about the
|
|
390
|
+
// member's file (oversize, symlink, unsafe tree) means the repo has not completed the handshake.
|
|
391
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml cannot be used: ${e.message}`, { commit: obs.commit, cause: e.code });
|
|
392
|
+
throw e;
|
|
393
|
+
}
|
|
394
|
+
const read = readDeclaration("membership", bytes, { kind: "membership", repoKey: key, commit: obs.commit, path: "oats-membership.yaml" });
|
|
395
|
+
if (read.problems) return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml is invalid: ${read.problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, { commit: obs.commit, problems: read.problems });
|
|
396
|
+
let backlinkKey;
|
|
397
|
+
try { backlinkKey = refKey(remote, read.value.workspace); }
|
|
398
|
+
catch (e) {
|
|
399
|
+
if (e?.code === "E_REPO_REF") return unconfirmed(key, "no-backlink", `${key}@${obs.commit.slice(0, 12)} oats-membership.yaml names an unparseable workspace ${show(read.value.workspace)}`, { commit: obs.commit });
|
|
400
|
+
throw e;
|
|
401
|
+
}
|
|
402
|
+
if (backlinkKey !== workspaceObs.key) {
|
|
403
|
+
const caseOnly = backlinkKey.toLowerCase() === workspaceObs.key.toLowerCase();
|
|
404
|
+
return unconfirmed(key, "backlink-elsewhere", `${key}@${obs.commit.slice(0, 12)} names workspace ${backlinkKey}, not ${workspaceObs.key}${caseOnly ? " (the keys differ only by letter case: repo paths are case-sensitive identities; spell the workspace ref exactly as the workspace lists itself)" : ""}`, { commit: obs.commit, backlink: backlinkKey, ...(caseOnly ? { caseOnly: true } : {}) });
|
|
405
|
+
}
|
|
406
|
+
return { key, commit: obs.commit, confirmed: true, team: read.value.team ?? null };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* ───────────────────────────── enumeration ────────────────────────────── */
|
|
410
|
+
|
|
411
|
+
const SOUL_FILE = /^([^/]+)\/soul\.yaml$/;
|
|
412
|
+
const CAP_FILE = /^([^/]+)\/oats\.json$/;
|
|
413
|
+
const teamOf = (item, fallback) => (typeof item?.team === "string" ? item.team : fallback ?? null);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Enumerate souls/*\/soul.yaml and capabilities/*\/oats.json of one repo at one commit.
|
|
417
|
+
* Validates each item; collects problems instead of aborting.
|
|
418
|
+
* → { souls: [SoulEntry], capabilities: [CapEntry], problems: [{ code, path, message, repoKey }] }
|
|
419
|
+
*/
|
|
420
|
+
async function enumerateRepo(remote, ref, key, commit, { defaultTeam = null, teams = null } = {}) {
|
|
421
|
+
const souls = [];
|
|
422
|
+
const capabilities = [];
|
|
423
|
+
const problems = [];
|
|
424
|
+
const problem = (code, path, message) => problems.push({ code, repoKey: key, path, message });
|
|
425
|
+
const checkTeam = (team, path) => {
|
|
426
|
+
if (team !== null && teams && !teams.includes(team)) problem("E_TEAM_UNKNOWN", path, `team ${show(team)} is not declared in the workspace's teams (${teams.join(", ") || "none"})`);
|
|
427
|
+
};
|
|
428
|
+
// A listing failure on one directory is a problem of THIS repo, never an abort of the whole picture.
|
|
429
|
+
const list = async (dir) => {
|
|
430
|
+
try { return await remote.listRemoteTree(ref, commit, dir, { depth: 2 }); }
|
|
431
|
+
catch (e) {
|
|
432
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) { problem(e.code, dir, e.message); return []; }
|
|
433
|
+
throw e;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const soulNames = new Map();
|
|
437
|
+
for (const entry of await list("souls")) {
|
|
438
|
+
const m = entry.type === "blob" && SOUL_FILE.exec(entry.path);
|
|
439
|
+
if (!m) continue;
|
|
440
|
+
const path = `souls/${m[1]}`;
|
|
441
|
+
const file = `${path}/soul.yaml`;
|
|
442
|
+
let bytes;
|
|
443
|
+
try { ({ bytes } = await remote.readRemoteFile(ref, commit, file)); }
|
|
444
|
+
catch (e) { problem(e?.code || "E_REMOTE_UNREADABLE", file, e.message); continue; }
|
|
445
|
+
const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
|
|
446
|
+
if (read.problems) { for (const p of read.problems) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
|
|
447
|
+
const definition = read.value;
|
|
448
|
+
if (definition.name !== m[1]) problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} does not match its directory ${show(m[1])}`);
|
|
449
|
+
if (soulNames.has(definition.name)) { problem("E_WORKSPACE_SCHEMA", `${file}#/name`, `soul name ${show(definition.name)} is already declared by ${soulNames.get(definition.name)}; the second declaration is not listed`); continue; }
|
|
450
|
+
soulNames.set(definition.name, file);
|
|
451
|
+
const team = teamOf(definition, defaultTeam);
|
|
452
|
+
checkTeam(team, `${file}#/team`);
|
|
453
|
+
souls.push({ name: definition.name, path, repoKey: key, commit, team, private: definition.private === true, definition });
|
|
454
|
+
}
|
|
455
|
+
const capNames = new Map();
|
|
456
|
+
for (const entry of await list("capabilities")) {
|
|
457
|
+
const m = entry.type === "blob" && CAP_FILE.exec(entry.path);
|
|
458
|
+
if (!m) continue;
|
|
459
|
+
const path = `capabilities/${m[1]}`;
|
|
460
|
+
const file = `${path}/oats.json`;
|
|
461
|
+
let bytes;
|
|
462
|
+
try { ({ bytes } = await remote.readRemoteFile(ref, commit, file)); }
|
|
463
|
+
catch (e) { problem(e?.code || "E_REMOTE_UNREADABLE", file, e.message); continue; }
|
|
464
|
+
const decoded = decodeDocument(bytes, { kind: "capability", repoKey: key, commit, path: file });
|
|
465
|
+
if (decoded.problems) { for (const p of decoded.problems) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, p.message); continue; }
|
|
466
|
+
const manifest = decoded.value;
|
|
467
|
+
// The fields discovery relies on. `capability` uses the SAME grammar as every `capabilities:` key
|
|
468
|
+
// in soul.yaml / oats-workspace.yaml (capabilityName): a name that cannot be referenced is not
|
|
469
|
+
// discoverable, and a name with `/` or `..` never becomes a module directory.
|
|
470
|
+
const shape = validateAgainst({
|
|
471
|
+
type: "object", required: ["capability", "version"],
|
|
472
|
+
properties: {
|
|
473
|
+
capability: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]*$" },
|
|
474
|
+
version: { type: "string", minLength: 1 },
|
|
475
|
+
private: { type: "boolean" },
|
|
476
|
+
team: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]*$" },
|
|
477
|
+
layer: { enum: ["knowledge", "messaging", "tasks"] },
|
|
478
|
+
},
|
|
479
|
+
}, manifest);
|
|
480
|
+
if (shape.length) { for (const p of shape) problem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, p.message); continue; }
|
|
481
|
+
if (capNames.has(manifest.capability)) { problem("E_WORKSPACE_SCHEMA", `${file}#/capability`, `capability ${show(manifest.capability)} is already declared by ${capNames.get(manifest.capability)}; the second declaration is not listed`); continue; }
|
|
482
|
+
capNames.set(manifest.capability, file);
|
|
483
|
+
const team = teamOf(manifest, defaultTeam);
|
|
484
|
+
checkTeam(team, `${file}#/team`);
|
|
485
|
+
capabilities.push({ name: manifest.capability, path, repoKey: key, commit, team, private: manifest.private === true, manifest });
|
|
486
|
+
}
|
|
487
|
+
// Non-collapse rule: a member's oats-package/ is REPORTED (publishes), never enumerated as member capabilities.
|
|
488
|
+
let publishes = null;
|
|
489
|
+
try {
|
|
490
|
+
const { bytes } = await remote.readRemoteFile(ref, commit, "oats-package/oats-package.json");
|
|
491
|
+
const decoded = decodeDocument(bytes, { kind: "package", repoKey: key, commit, path: "oats-package/oats-package.json" });
|
|
492
|
+
if (decoded.problems) for (const p of decoded.problems) problem("E_WORKSPACE_SCHEMA", `oats-package/oats-package.json#${p.path}`, p.message);
|
|
493
|
+
else if (isObject(decoded.value) && typeof decoded.value.package === "string") publishes = { package: decoded.value.package, version: typeof decoded.value.version === "string" ? decoded.value.version : null };
|
|
494
|
+
else problem("E_WORKSPACE_SCHEMA", "oats-package/oats-package.json#/package", "package manifest must declare \"package\"");
|
|
495
|
+
} catch (e) {
|
|
496
|
+
if (e?.code !== "E_REMOTE_PATH_MISSING") {
|
|
497
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_REMOTE_")) problem(e.code, "oats-package/oats-package.json", e.message);
|
|
498
|
+
else throw e;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { souls, capabilities, publishes, problems };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Enumerate one readable repo without a workspace view (the standalone input):
|
|
506
|
+
* → { key, commit, membership: <parsed or null>, souls, capabilities, problems }
|
|
507
|
+
*/
|
|
508
|
+
export async function discoverRepo(ref, { at, remote: injected, remoteOptions } = {}) {
|
|
509
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
510
|
+
const obs = await remote.observeRemote(ref, { at });
|
|
511
|
+
let membership = null;
|
|
512
|
+
const problems = [];
|
|
513
|
+
try {
|
|
514
|
+
const { bytes } = await remote.readRemoteFile(ref, obs.commit, "oats-membership.yaml");
|
|
515
|
+
const read = readDeclaration("membership", bytes, { kind: "membership", repoKey: obs.key, commit: obs.commit, path: "oats-membership.yaml" });
|
|
516
|
+
if (read.problems) for (const p of read.problems) problems.push({ code: "E_WORKSPACE_SCHEMA", repoKey: obs.key, path: `oats-membership.yaml#${p.path}`, message: p.message });
|
|
517
|
+
else membership = read.value;
|
|
518
|
+
} catch (e) {
|
|
519
|
+
if (e?.code !== "E_REMOTE_PATH_MISSING") throw e;
|
|
520
|
+
}
|
|
521
|
+
const items = await enumerateRepo(remote, ref, obs.key, obs.commit, { defaultTeam: membership?.team ?? null, teams: null });
|
|
522
|
+
return { key: obs.key, commit: obs.commit, membership, ...items, problems: [...problems, ...items.problems] };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The whole picture in one access context.
|
|
527
|
+
* → { workspace, members: [{ key, commit, confirmed, reason?, team, souls, capabilities, publishes }], external: [...],
|
|
528
|
+
* problems: [{ code, path, message, repoKey? }] }
|
|
529
|
+
* `publishes` = { package, version } when the member carries oats-package/oats-package.json (informational:
|
|
530
|
+
* the package's capabilities are NOT member capabilities — non-collapse rule), else null.
|
|
531
|
+
*/
|
|
532
|
+
export async function discoverWorkspace(ref, { at, local, remote: injected, remoteOptions } = {}) {
|
|
533
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
534
|
+
const wsObs = await observeWorkspace(ref, { at, remote });
|
|
535
|
+
const workspace = wsObs.workspace;
|
|
536
|
+
const teams = isObject(workspace.teams) ? Object.keys(workspace.teams) : [];
|
|
537
|
+
const problems = [];
|
|
538
|
+
const members = [];
|
|
539
|
+
for (const memberRef of workspace.members || []) {
|
|
540
|
+
const confirmation = await confirmMembership(wsObs, memberRef, { remote });
|
|
541
|
+
const row = { key: confirmation.key, commit: confirmation.commit ?? null, confirmed: confirmation.confirmed, team: confirmation.team ?? null, souls: [], capabilities: [], publishes: null };
|
|
542
|
+
if (!confirmation.confirmed) {
|
|
543
|
+
// Contract: an unconfirmed member contributes nothing but its row.
|
|
544
|
+
row.reason = confirmation.reason;
|
|
545
|
+
row.detail = confirmation.detail;
|
|
546
|
+
members.push(row);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (row.team !== null && !teams.includes(row.team)) problems.push({ code: "E_TEAM_UNKNOWN", repoKey: row.key, path: "oats-membership.yaml#/team", message: `team ${show(row.team)} is not declared in the workspace's teams (${teams.join(", ") || "none"})` });
|
|
550
|
+
const items = await enumerateRepo(remote, memberRef, row.key, row.commit, { defaultTeam: row.team, teams });
|
|
551
|
+
row.souls = items.souls;
|
|
552
|
+
row.capabilities = items.capabilities;
|
|
553
|
+
row.publishes = items.publishes;
|
|
554
|
+
problems.push(...items.problems);
|
|
555
|
+
members.push(row);
|
|
556
|
+
}
|
|
557
|
+
const externalRows = [];
|
|
558
|
+
for (const [index, entry] of (workspace.external || []).entries()) {
|
|
559
|
+
const pin = entry.source.lastIndexOf("@");
|
|
560
|
+
const sourceRef = entry.source.slice(0, pin);
|
|
561
|
+
const commit = entry.source.slice(pin + 1);
|
|
562
|
+
const file = `${entry.soul.replace(/\/+$/, "")}/soul.yaml`;
|
|
563
|
+
const pushProblem = (code, path, message) => problems.push({ code, repoKey: null, path: `external/${index}:${path}`, message, source: entry.source });
|
|
564
|
+
let key;
|
|
565
|
+
try { key = refKey(remote, sourceRef); }
|
|
566
|
+
catch (e) { if (e?.code === "E_REPO_REF") { pushProblem("E_REPO_REF", "source", e.message); continue; } throw e; }
|
|
567
|
+
let bytes;
|
|
568
|
+
try {
|
|
569
|
+
// Pinned: read at the declared OID only; the remote's default branch is never consulted.
|
|
570
|
+
({ bytes } = await remote.readRemoteFile(sourceRef, commit, file));
|
|
571
|
+
} catch (e) {
|
|
572
|
+
if (e?.code === "E_REMOTE_UNREADABLE" || e?.code === "E_REMOTE_PATH_MISSING") { pushProblem(e.code, file, e.message); continue; }
|
|
573
|
+
throw e;
|
|
574
|
+
}
|
|
575
|
+
const read = readDeclaration("soul", bytes, { kind: "soul", repoKey: key, commit, path: file });
|
|
576
|
+
if (read.problems) { for (const p of read.problems) pushProblem("E_WORKSPACE_SCHEMA", `${file}#${p.path}`, `${p.message}${schemaHint("soul", read.value)}`); continue; }
|
|
577
|
+
const team = teamOf(entry, null) ?? teamOf(read.value, null);
|
|
578
|
+
if (team !== null && !teams.includes(team)) pushProblem("E_TEAM_UNKNOWN", `${file}#/team`, `team ${show(team)} is not declared in the workspace's teams`);
|
|
579
|
+
externalRows.push({ source: entry.source, key, commit, soul: { name: read.value.name, path: entry.soul, repoKey: key, commit, team, private: read.value.private === true, definition: read.value } });
|
|
580
|
+
}
|
|
581
|
+
return { workspace, key: wsObs.key, url: wsObs.url, commit: wsObs.commit, observedAt: wsObs.observedAt, local: local ?? null, members, external: externalRows, problems };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* For a readable MEMBER whose workspace cannot be read (decision 10): its souls with from:here
|
|
586
|
+
* capabilities only. `discovery` is the repo's own enumeration (discoverRepo(...) or a
|
|
587
|
+
* discoverWorkspace(...) result containing the repo); workspace defaults do not apply and are
|
|
588
|
+
* not known here. Membership itself (oats-membership.yaml present) is the CALLER's gate —
|
|
589
|
+
* discoverOrStandalone refuses a repo with no backlink (E_MEMBERSHIP_UNCONFIRMED no-backlink)
|
|
590
|
+
* before ever building this view.
|
|
591
|
+
*/
|
|
592
|
+
/** The capability every standalone spawn gets by default (decision 25). */
|
|
593
|
+
export const STANDALONE_DEFAULT_CAPABILITY = "oats.core";
|
|
594
|
+
export function standaloneRepo(ref, commit, discovery, { remote: injected } = {}) {
|
|
595
|
+
const remote = remoteOf({ remote: injected });
|
|
596
|
+
const key = refKey(remote, ref);
|
|
597
|
+
if (!discovery) throw fail("E_MEMBERSHIP_UNCONFIRMED", `standaloneRepo needs the repo's enumeration (discoverRepo) for ${key}`, { key, commit, reason: "cannot-read" });
|
|
598
|
+
const source = Array.isArray(discovery.members) ? discovery.members.find((m) => m.key === key) : discovery;
|
|
599
|
+
if (!source || (source.key && source.key !== key)) throw fail("E_MEMBERSHIP_UNCONFIRMED", `discovery does not describe ${key}`, { key, commit, reason: "cannot-read" });
|
|
600
|
+
if (source.commit && commit && source.commit !== commit) throw fail("E_MEMBERSHIP_UNCONFIRMED", `discovery of ${key} is at ${source.commit}, not ${commit}`, { key, commit, observed: source.commit, reason: "cannot-read" });
|
|
601
|
+
const problems = [];
|
|
602
|
+
const capabilities = (source.capabilities || []).filter((c) => c.repoKey === key);
|
|
603
|
+
const capNames = new Set(capabilities.map((c) => c.name));
|
|
604
|
+
const souls = (source.souls || []).filter((s) => s.repoKey === key).map((soul) => {
|
|
605
|
+
// Decision 25: the framework's own operational package is the kernel's default
|
|
606
|
+
// even when the workspace (and its defaults) cannot be read. ANY mention of
|
|
607
|
+
// oats.core in the soul — `{ from: package }`, `{ from: here }`, `{ from: <repo> }`
|
|
608
|
+
// or `off` — suppresses the kernel default: the soul's own choice is then judged
|
|
609
|
+
// by the loop below exactly like every other capability (S4).
|
|
610
|
+
const kept = {};
|
|
611
|
+
const declaredCaps = soul.definition.capabilities || {};
|
|
612
|
+
if (!Object.hasOwn(declaredCaps, STANDALONE_DEFAULT_CAPABILITY)) kept[STANDALONE_DEFAULT_CAPABILITY] = { from: "package" };
|
|
613
|
+
for (const [name, choice] of Object.entries(declaredCaps)) {
|
|
614
|
+
const from = isObject(choice) ? choice.from : null;
|
|
615
|
+
if (choice === "off") continue;
|
|
616
|
+
if (from === "here" || from === key) {
|
|
617
|
+
if (!capNames.has(name)) problems.push({ code: "E_CAPABILITY_MISSING", repoKey: key, path: `${soul.path}/soul.yaml#/capabilities/${pointerKey(name)}`, message: `${key} has no capabilities/*/oats.json declaring ${show(name)}` });
|
|
618
|
+
else kept[name] = { from: "here" };
|
|
619
|
+
} else if (from === "package") {
|
|
620
|
+
// The operator's lock (oats sync against a catalog) is where package
|
|
621
|
+
// capabilities resolve; standalone, only the kernel default is honoured.
|
|
622
|
+
if (name === STANDALONE_DEFAULT_CAPABILITY) { kept[name] = { from: "package" }; continue; }
|
|
623
|
+
problems.push({ code: "E_PACKAGE_MISSING", repoKey: key, path: `${soul.path}/soul.yaml#/capabilities/${pointerKey(name)}`, message: `${show(name)} comes from a package, but the workspace's packages cannot be read standalone` });
|
|
624
|
+
} else {
|
|
625
|
+
problems.push({ code: "E_NOT_A_MEMBER", repoKey: key, path: `${soul.path}/soul.yaml#/capabilities/${pointerKey(name)}`, message: `${show(name)} comes from ${show(from)}, which cannot be confirmed as a member without the workspace` });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return { ...soul, capabilities: kept };
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
standalone: true, key, commit: source.commit ?? commit ?? null, workspace: null,
|
|
632
|
+
members: [{ key, commit: source.commit ?? commit ?? null, confirmed: false, reason: "cannot-read", detail: `workspace of ${key} cannot be read; standalone view`, team: source.team ?? source.membership?.team ?? null, souls, capabilities, publishes: source.publishes ?? null }],
|
|
633
|
+
external: [], problems,
|
|
634
|
+
};
|
|
635
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|