@awebai/oats 0.25.0 → 0.25.2
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 +219 -56
- package/docs/configuration.md +3 -3
- package/docs/conventions.md +51 -24
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +1 -1
- package/docs/design/2026-09-23-simplified-workspace-model.md +5 -5
- package/docs/design/2026-09-23-workspace-module-contracts.md +257 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +5 -5
- package/docs/desktop-cli-api.md +33 -1
- package/docs/desktop-succession.md +9 -2
- package/docs/desktop.md +9 -4
- package/docs/execution-targets.md +16 -4
- package/docs/first-team.md +3 -3
- package/docs/implementation.md +35 -7
- package/docs/integrations.md +5 -3
- package/docs/knowledge-migration.md +16 -8
- package/docs/knowledge.md +50 -17
- package/docs/migration-from-oas.md +20 -9
- package/docs/rebuild-to-v2.md +295 -25
- package/docs/release-notes/v0.25.1.md +94 -0
- package/docs/release-notes/v0.25.2.md +80 -0
- package/docs/schedules.md +12 -6
- package/docs/souls-and-instances.md +36 -18
- package/docs/workspaces.md +73 -27
- package/lib/core.mjs +76 -10
- package/lib/instance-resolution.mjs +228 -23
- package/lib/materialize.mjs +29 -0
- package/lib/operator-dispatch.mjs +117 -0
- package/lib/packages.mjs +62 -1
- package/lib/remote.mjs +128 -49
- package/lib/resolve.mjs +70 -8
- package/lib/workspace.mjs +25 -6
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -36,9 +36,8 @@ import {
|
|
|
36
36
|
} from "../lib/core.mjs";
|
|
37
37
|
import {
|
|
38
38
|
assertNoSymlinkedParents, writeFileAtomic,
|
|
39
|
-
LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage,
|
|
40
|
-
classifyPackageValue,
|
|
41
|
-
} from "../lib/packages.mjs";
|
|
39
|
+
LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage,
|
|
40
|
+
classifyPackageValue, parsePackageRequest, executablesDigestAt } from "../lib/packages.mjs";
|
|
42
41
|
import { loadLocal, discoverWorkspace, validateWorkspace } from "../lib/workspace.mjs";
|
|
43
42
|
import * as remoteModule from "../lib/remote.mjs";
|
|
44
43
|
import { createInterface } from "node:readline/promises";
|
|
@@ -1941,21 +1940,47 @@ function printTable(header, rows) {
|
|
|
1941
1940
|
|
|
1942
1941
|
/** Executables digest of a locked package, read over the remote at its locked commit. */
|
|
1943
1942
|
async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOptions) {
|
|
1943
|
+
// ONE definition of the approval digest (lib/packages.mjs executablesDigestAt) — the
|
|
1944
|
+
// same function resolveSoul re-runs at spawn (M3), so sync and spawn can never disagree.
|
|
1944
1945
|
const req = parsePackageRequest(id, workspace.packages[id], catalog);
|
|
1945
|
-
const
|
|
1946
|
-
const targets =
|
|
1947
|
-
return { digest
|
|
1946
|
+
const { digest, executables } = await executablesDigestAt(remoteModule, req.remoteRef, entry.commit, entry.path, entry.capabilities ?? null, { remoteOptions });
|
|
1947
|
+
const targets = executables.map((x) => `${x.capability}: ${x.kind} ${x.name} → ${x.target}`);
|
|
1948
|
+
return { digest, targets };
|
|
1948
1949
|
}
|
|
1949
1950
|
|
|
1950
|
-
/** One yes/no question on the terminal (TTY only; the caller checks).
|
|
1951
|
+
/** One yes/no question on the terminal (TTY only; the caller checks). Ctrl+D / a closed
|
|
1952
|
+
* stdin at the prompt is "no" (readline rejects the question with AbortError, or simply
|
|
1953
|
+
* closes without an answer — neither is a crash; both are the operator declining). */
|
|
1951
1954
|
async function askYesNo(question) {
|
|
1952
1955
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1953
1956
|
try {
|
|
1954
|
-
const
|
|
1955
|
-
|
|
1957
|
+
const closed = new Promise((resolveClosed) => rl.once("close", () => resolveClosed(null)));
|
|
1958
|
+
const answer = await Promise.race([rl.question(question).catch((e) => { if (e?.code === "ABORT_ERR" || e?.name === "AbortError") return null; throw e; }), closed]);
|
|
1959
|
+
if (answer === null) { process.stderr.write("\n"); return false; }
|
|
1960
|
+
const a = String(answer).trim().toLowerCase();
|
|
1961
|
+
return a === "y" || a === "yes";
|
|
1956
1962
|
} finally { rl.close(); }
|
|
1957
1963
|
}
|
|
1958
1964
|
|
|
1965
|
+
/** `--approve <id>@<version>` (repeatable) → Map<id, version>. Exactly one "@" splits the two;
|
|
1966
|
+
* the digest is never taken from the operator — it is computed over the locked tree. */
|
|
1967
|
+
function approveFlags(bail) {
|
|
1968
|
+
const out = new Map();
|
|
1969
|
+
for (let i = 0; i < args.length; i++) {
|
|
1970
|
+
if (args[i] !== "--approve") continue;
|
|
1971
|
+
const v = args[i + 1];
|
|
1972
|
+
if (v === undefined || v.startsWith("--")) return bail("E_BAD_ARGS", "--approve needs <id>@<version> (the package id and the version exactly as `packages:` / the lock spell them)");
|
|
1973
|
+
const at = v.indexOf("@");
|
|
1974
|
+
const id = at > 0 ? v.slice(0, at) : "";
|
|
1975
|
+
const version = at > 0 ? v.slice(at + 1) : "";
|
|
1976
|
+
if (!id || !version || version.includes("@")) return bail("E_BAD_ARGS", `--approve ${JSON.stringify(v)}: expected <id>@<version>`, { value: v });
|
|
1977
|
+
if (out.has(id) && out.get(id) !== version) return bail("E_BAD_ARGS", `--approve names ${id} twice with different versions (${out.get(id)}, ${version})`, { id, versions: [out.get(id), version] });
|
|
1978
|
+
out.set(id, version);
|
|
1979
|
+
i++;
|
|
1980
|
+
}
|
|
1981
|
+
return out;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1959
1984
|
/** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
|
|
1960
1985
|
* way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
|
|
1961
1986
|
* `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
|
|
@@ -1982,6 +2007,16 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
|
|
|
1982
2007
|
let lock = resolved.lock;
|
|
1983
2008
|
const approvalNeeded = [];
|
|
1984
2009
|
const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
|
|
2010
|
+
// `--approve <id>@<version>`: approve what is THERE — every named pair must be a locked entry
|
|
2011
|
+
// at exactly that version (E_BAD_ARGS otherwise); the digest recorded is the one computed over
|
|
2012
|
+
// the locked tree, never anything the operator typed. Works without a terminal.
|
|
2013
|
+
const approveWanted = approveFlags(bail);
|
|
2014
|
+
for (const [id, version] of approveWanted) {
|
|
2015
|
+
const entry = lock.packages[id];
|
|
2016
|
+
if (!entry) return bail("E_BAD_ARGS", `--approve ${id}@${version}: ${id} is not a package of this ${discovery.standalone === true ? "standalone view" : "workspace"} (locked: ${Object.keys(lock.packages).sort().join(", ") || "none"})`, { id, version, locked: Object.keys(lock.packages).sort() });
|
|
2017
|
+
if (entry.version !== version) return bail("E_BAD_ARGS", `--approve ${id}@${version}: the lock resolves ${id} to version ${entry.version} (@ ${short(entry.commit)}) — approve what is there: --approve ${id}@${entry.version}`, { id, version, locked: entry.version, commit: entry.commit });
|
|
2018
|
+
}
|
|
2019
|
+
const approvedNow = [];
|
|
1985
2020
|
for (const id of Object.keys(lock.packages)) {
|
|
1986
2021
|
const entry = lock.packages[id];
|
|
1987
2022
|
if (entry.approved) continue;
|
|
@@ -1991,6 +2026,7 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
|
|
|
1991
2026
|
if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
|
|
1992
2027
|
throw e;
|
|
1993
2028
|
}
|
|
2029
|
+
if (approveWanted.has(id)) { lock = approvePackage(lock, id, digest); approvedNow.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets }); continue; }
|
|
1994
2030
|
if (interactive) {
|
|
1995
2031
|
console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
|
|
1996
2032
|
for (const t of targets) console.error(` ${t}`);
|
|
@@ -2001,12 +2037,27 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
|
|
|
2001
2037
|
}
|
|
2002
2038
|
let lockFile;
|
|
2003
2039
|
try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
2040
|
+
// The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
|
|
2041
|
+
// oats-local.yaml + sync is a complete deployment; spawn must not answer E_NO_DEPLOYMENT after it.
|
|
2042
|
+
try { mkdirSync(join(ctx.deploymentDir, "agents"), { recursive: true }); } catch { /* reported by spawn's E_NO_DEPLOYMENT remedy if it matters */ }
|
|
2004
2043
|
const members = memberRows(discovery);
|
|
2005
2044
|
const packages = packageRows(lock);
|
|
2006
2045
|
const changes = resolved.changes;
|
|
2007
2046
|
const items = workspaceItems(discovery, lock, { includePrivate: true });
|
|
2008
|
-
const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, approvalNeeded, problems };
|
|
2009
|
-
return { report, lock, discovery, approvalNeeded, interactive, items, lockFile, problems };
|
|
2047
|
+
const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, approvalNeeded, ...(approvedNow.length ? { approved: approvedNow } : {}), problems };
|
|
2048
|
+
return { report, lock, discovery, approvalNeeded, approvedNow, interactive, items, lockFile, problems };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/** The one-line standalone explanation (decision 10). `standaloneReason` says WHY the view is
|
|
2052
|
+
* standalone: asked for (`standalone:` in oats-local.yaml) or forced (the host is unreadable —
|
|
2053
|
+
* then the access failure is named). Never "cannot be read" when nothing was refused. */
|
|
2054
|
+
function standaloneNote(discovery, { from = "" } = {}) {
|
|
2055
|
+
if (discovery?.standalone !== true) return null;
|
|
2056
|
+
const who = memberLabel(discovery.key);
|
|
2057
|
+
if (discovery.standaloneReason === "explicit") return `standalone view of ${who} (explicit in oats-local.yaml): its own souls + oats.core`;
|
|
2058
|
+
const hf = discovery.hostFailure;
|
|
2059
|
+
const why = hf ? ` (${hf.code ?? "E_REMOTE_UNREADABLE"}${hf.reason ? `: ${hf.reason}` : ""}${hf.url ? ` — ${hf.url}` : ""})` : "";
|
|
2060
|
+
return `standalone — the workspace of ${who} cannot be read${from}${why}; its own souls + oats.core`;
|
|
2010
2061
|
}
|
|
2011
2062
|
|
|
2012
2063
|
/** The human §8 report of a sync (text mode). */
|
|
@@ -2014,7 +2065,7 @@ function printSyncReport(ctx, synced) {
|
|
|
2014
2065
|
const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
|
|
2015
2066
|
const { members, packages, changes } = report;
|
|
2016
2067
|
const disabled = new Set(ctx.local.souls?.disabled || []);
|
|
2017
|
-
console.log(`workspace ${discovery.workspace?.name ?? `(
|
|
2068
|
+
console.log(`workspace ${discovery.workspace?.name ?? `(${standaloneNote(discovery)})`} (${discovery.key} @ ${short(discovery.commit)})`);
|
|
2018
2069
|
console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
|
|
2019
2070
|
console.log(`packages ${packages.map((p) => {
|
|
2020
2071
|
const need = approvalNeeded.find((a) => a.id === p.id);
|
|
@@ -2033,6 +2084,7 @@ function printSyncReport(ctx, synced) {
|
|
|
2033
2084
|
for (const c of items.capabilities.filter((c) => c.kind === "member")) { const t = teams.get(c.team) || { souls: 0, capabilities: 0 }; t.capabilities++; teams.set(c.team, t); }
|
|
2034
2085
|
console.log(`teams ${[...teams.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([team, n]) => `${team} ${n.souls} soul${n.souls === 1 ? "" : "s"}${n.capabilities ? `, ${n.capabilities} capabilit${n.capabilities === 1 ? "y" : "ies"}` : ""}`).join(" · ") || "(none)"}`);
|
|
2035
2086
|
for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
|
|
2087
|
+
for (const a of synced.approvedNow ?? []) console.log(`approved ${a.id} ${a.version} @ ${short(a.commit)} (--approve; ${a.targets.length} executable${a.targets.length === 1 ? "" : "s"}, digest ${a.executables})`);
|
|
2036
2088
|
if (approvalNeeded.length) {
|
|
2037
2089
|
console.log(`\nApproval needed for ${approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")} — ${interactive ? "declined; " : "not a terminal; "}the lock records them unapproved. Run \`oats sync\` in a terminal to approve their executables (spawns of souls using them are refused until then).`);
|
|
2038
2090
|
} else console.log(`\nlock ${shortPath(lockFile)}`);
|
|
@@ -2156,7 +2208,7 @@ async function workspaceCmd() {
|
|
|
2156
2208
|
const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, approval, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems };
|
|
2157
2209
|
if (JSON_MODE) { jsonOk(result); return; }
|
|
2158
2210
|
console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
|
|
2159
|
-
if (standalone) console.log(` (
|
|
2211
|
+
if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
|
|
2160
2212
|
console.log("Members:");
|
|
2161
2213
|
printTable(["member", "status", "commit", "team", "souls", "capabilities", "publishes"], members.map((m) => [m.name, m.status, short(m.commit), m.team ?? "—", m.souls.join(",") || "—", m.capabilities.join(",") || "—", m.publishes ? `${m.publishes.package} v${m.publishes.version ?? "?"}` : "—"]));
|
|
2162
2214
|
for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
|
|
@@ -2179,7 +2231,7 @@ async function itemsCmd(kind) {
|
|
|
2179
2231
|
const items = workspaceItems(discovery, lock)[kind];
|
|
2180
2232
|
const standalone = discovery.standalone === true;
|
|
2181
2233
|
if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
|
|
2182
|
-
console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` —
|
|
2234
|
+
console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
|
|
2183
2235
|
if (!items.length) console.log(" (none)");
|
|
2184
2236
|
else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
|
|
2185
2237
|
else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
|
|
@@ -2190,13 +2242,25 @@ async function itemsCmd(kind) {
|
|
|
2190
2242
|
// ---------- roster: status / spawn / retire / create ----------
|
|
2191
2243
|
/** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
|
|
2192
2244
|
* the remotes serves every instance; `driftOf` compares each instance's recorded modules to
|
|
2193
|
-
* the members' current state (and package modules to the lock)
|
|
2194
|
-
*
|
|
2245
|
+
* the members' current state (and package modules to the lock), `soulDriftOf` the instance's
|
|
2246
|
+
* recorded SOUL SOURCE (instance.json.workspace.soul) to its member's current commit. Offline →
|
|
2247
|
+
* { unreachable }. Returns null when the deployment is not a workspace deployment (no
|
|
2248
|
+
* oats-local.yaml). `souls` maps each workspace soul NAME (agents/<name>/.oats-soul-source.json
|
|
2249
|
+
* present) to its stamp — the roster's `repo:` column for a workspace soul. */
|
|
2195
2250
|
async function statusDrift(data) {
|
|
2196
2251
|
let ctx;
|
|
2197
2252
|
try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
|
|
2198
|
-
const hasModules =
|
|
2199
|
-
|
|
2253
|
+
const hasModules = (i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length > 0;
|
|
2254
|
+
const hasSoul = (i) => i.workspace && typeof i.workspace === "object" && i.workspace.soul && typeof i.workspace.soul === "object" && typeof i.workspace.soul.repoKey === "string";
|
|
2255
|
+
// Workspace souls of this roster: the stamp ensureWorkspaceSoul leaves beside the soul pointer.
|
|
2256
|
+
const souls = new Map();
|
|
2257
|
+
for (const a of data) {
|
|
2258
|
+
if (!a.dir) continue;
|
|
2259
|
+
try { const stamp = JSON.parse(readFileSync(join(a.dir, ".oats-soul-source.json"), "utf8")); if (stamp && typeof stamp.repoKey === "string") souls.set(a.name, { repoKey: stamp.repoKey, commit: typeof stamp.commit === "string" ? stamp.commit : null, path: stamp.path ?? null }); }
|
|
2260
|
+
catch { /* not a workspace soul (classic, capability agent, or unreadable stamp) */ }
|
|
2261
|
+
}
|
|
2262
|
+
const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
|
|
2263
|
+
if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
|
|
2200
2264
|
const deploymentDir = dirname(ctx.path);
|
|
2201
2265
|
let lock = null;
|
|
2202
2266
|
try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
|
|
@@ -2206,15 +2270,22 @@ async function statusDrift(data) {
|
|
|
2206
2270
|
try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
|
|
2207
2271
|
catch (e) {
|
|
2208
2272
|
const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
|
|
2209
|
-
return { drift: new Map(), unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
|
|
2273
|
+
return { drift: new Map(), soul: new Map(), souls, unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
|
|
2210
2274
|
}
|
|
2211
|
-
const { driftOf } = await import("../lib/materialize.mjs");
|
|
2275
|
+
const { driftOf, soulDriftOf } = await import("../lib/materialize.mjs");
|
|
2212
2276
|
const drift = new Map();
|
|
2277
|
+
const soul = new Map();
|
|
2213
2278
|
for (const a of data) for (const i of a.instances || []) {
|
|
2214
|
-
|
|
2215
|
-
try { drift.set(
|
|
2279
|
+
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
2280
|
+
if (hasModules(i)) { try { drift.set(key, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ } }
|
|
2281
|
+
if (hasSoul(i)) { try { const row = soulDriftOf(i, discovery); if (row) soul.set(key, row); } catch { /* an unreadable soul record shows as no soul row */ } }
|
|
2282
|
+
}
|
|
2283
|
+
// The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
|
|
2284
|
+
for (const [, stamp] of souls) {
|
|
2285
|
+
const member = discovery.members.find((m) => m && m.key === stamp.repoKey);
|
|
2286
|
+
if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
|
|
2216
2287
|
}
|
|
2217
|
-
return { drift, unreachable: null };
|
|
2288
|
+
return { drift, soul, souls, unreachable: null };
|
|
2218
2289
|
}
|
|
2219
2290
|
/** One `modules:` line per module. */
|
|
2220
2291
|
function driftLine(row) {
|
|
@@ -2225,6 +2296,20 @@ function driftLine(row) {
|
|
|
2225
2296
|
if (row.status === "missing") return `${base} [${row.reason === "capability-absent" ? "capability no longer present" : row.reason === "package-absent" ? "package no longer locked" : `member ${row.reason || "unconfirmed"}`}]`;
|
|
2226
2297
|
return base;
|
|
2227
2298
|
}
|
|
2299
|
+
/** The `soul:` line of an instance — decision 17 for the soul source. */
|
|
2300
|
+
function soulDriftLine(row, agentName) {
|
|
2301
|
+
const base = `soul: ${row.name ?? agentName} from ${memberLabel(row.repoKey)} @ ${short7(row.commit)}`;
|
|
2302
|
+
if (row.status === "moved") return `${base} [member moved since (now @ ${short7(row.current?.commit)})]`;
|
|
2303
|
+
if (row.status === "missing") return `${base} [${row.reason === "soul-absent" ? "soul no longer present" : `member ${row.reason || "unconfirmed"}`}]`;
|
|
2304
|
+
return base;
|
|
2305
|
+
}
|
|
2306
|
+
/** The roster's `[work: …, repo: …]` for a soul: a workspace soul names its member and commit. */
|
|
2307
|
+
function soulRepoLabel(a, ws) {
|
|
2308
|
+
const stamp = ws?.souls?.get(a.name);
|
|
2309
|
+
if (!stamp) return a.repo || "?";
|
|
2310
|
+
const moved = stamp.current && stamp.commit && stamp.current !== stamp.commit ? ` (member now @ ${short7(stamp.current)})` : "";
|
|
2311
|
+
return `${memberLabel(stamp.repoKey)} @ ${short7(stamp.commit)}${moved}`;
|
|
2312
|
+
}
|
|
2228
2313
|
const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
|
|
2229
2314
|
|
|
2230
2315
|
async function status() {
|
|
@@ -2236,18 +2321,31 @@ async function status() {
|
|
|
2236
2321
|
const ws = await statusDrift(data);
|
|
2237
2322
|
const verbose = args.includes("--verbose");
|
|
2238
2323
|
if (args.includes("--json")) {
|
|
2239
|
-
if (ws) for (const a of data)
|
|
2324
|
+
if (ws) for (const a of data) {
|
|
2325
|
+
const stamp = ws.souls.get(a.name);
|
|
2326
|
+
if (stamp) a.soulSource = { repoKey: stamp.repoKey, commit: stamp.commit, path: stamp.path, ...(stamp.current ? { current: stamp.current, status: stamp.current === stamp.commit ? "current" : "moved" } : {}) };
|
|
2327
|
+
for (const i of a.instances || []) {
|
|
2328
|
+
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
2329
|
+
const rows = ws.drift.get(key);
|
|
2330
|
+
if (rows) i.modules = rows.map((r) => ({ name: r.module, from: r.from, commit: r.recorded?.commit ?? null, current: r.current, status: r.status, ...(r.reason ? { reason: r.reason } : {}) }));
|
|
2331
|
+
const s = ws.soul.get(key);
|
|
2332
|
+
if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}) };
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2240
2335
|
console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
|
|
2241
2336
|
}
|
|
2242
2337
|
console.log(`oats status — agents root ${shortPath(root)}\n`);
|
|
2243
2338
|
if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
|
|
2244
2339
|
if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
|
|
2245
2340
|
for (const a of data) {
|
|
2246
|
-
console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a
|
|
2341
|
+
console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
|
|
2247
2342
|
if (a.description) console.log(` ${a.description}`);
|
|
2248
2343
|
for (const i of a.instances) {
|
|
2249
2344
|
console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
|
|
2250
|
-
const
|
|
2345
|
+
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
2346
|
+
const s = ws?.soul.get(key);
|
|
2347
|
+
if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
|
|
2348
|
+
const rows = ws?.drift.get(key) || [];
|
|
2251
2349
|
for (const r of rows) if (verbose || r.status !== "current") console.log(` ${driftLine(r)}`);
|
|
2252
2350
|
}
|
|
2253
2351
|
for (const f of a.retireFailures || []) {
|
|
@@ -2501,13 +2599,20 @@ async function spawnCmd() {
|
|
|
2501
2599
|
// package = locked+approved) and copied whole into the new home. Without one
|
|
2502
2600
|
// (a bare agents root, tests) the classic soul-directory spawn proceeds.
|
|
2503
2601
|
let prepared;
|
|
2602
|
+
// Workspace model: a `work: worktree|checkout` soul works IN its member's clone on
|
|
2603
|
+
// this machine (design doc §4): --repo, else oats-local.yaml `clones:`, else the
|
|
2604
|
+
// convention <deployment>/<member name>. Never an ambient Git checkout around the
|
|
2605
|
+
// deployment. Resolved once here so a preview sees exactly what the apply would.
|
|
2606
|
+
let preparedRepo;
|
|
2504
2607
|
if (wsPrepared) {
|
|
2505
2608
|
try {
|
|
2506
|
-
const { toCapabilityRows, modulesPreview } = await import("../lib/instance-resolution.mjs");
|
|
2609
|
+
const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
|
|
2507
2610
|
prepared = wsPrepared;
|
|
2508
2611
|
prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
|
|
2509
2612
|
prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
|
|
2510
2613
|
prepared.toCapabilityRows = toCapabilityRows;
|
|
2614
|
+
const effectiveWork = requestedWork || agent.work || "checkout";
|
|
2615
|
+
if (effectiveWork === "worktree" || effectiveWork === "checkout") preparedRepo = requireMemberClone(prepared, { explicit: repo });
|
|
2511
2616
|
} catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
|
|
2512
2617
|
}
|
|
2513
2618
|
let r;
|
|
@@ -2520,7 +2625,7 @@ async function spawnCmd() {
|
|
|
2520
2625
|
...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
|
|
2521
2626
|
// Directory execution uses deployment configuration, not an ambient Git
|
|
2522
2627
|
// checkout (especially when invoked via --dir from a source instance).
|
|
2523
|
-
repo: (requestedWork || agent.work) === "directory"
|
|
2628
|
+
repo: preparedRepo !== undefined ? preparedRepo : (requestedWork || agent.work) === "directory"
|
|
2524
2629
|
? (repo ?? agent.repo) : repo || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
|
|
2525
2630
|
work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
|
|
2526
2631
|
launchConfig: valueFlag("launch-config"),
|
|
@@ -2822,7 +2927,7 @@ async function paneCmd() {
|
|
|
2822
2927
|
|
|
2823
2928
|
/** `oats onboard [<dir>] --workspace <repo ref> [--json]` — workspace model v2 (decision 9).
|
|
2824
2929
|
*
|
|
2825
|
-
* Realizes a workspace on this machine in the
|
|
2930
|
+
* Realizes a workspace on this machine in the directory the operator chooses (decision 9)
|
|
2826
2931
|
* (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
|
|
2827
2932
|
* `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
|
|
2828
2933
|
* instance homes), then runs exactly the `oats sync` path — discover over the
|
|
@@ -2913,9 +3018,20 @@ async function onboardCmd() {
|
|
|
2913
3018
|
const spawnHint = setupExpert ? `oats spawn oats-setup-expert --dir ${shortPath(dir)}` : null;
|
|
2914
3019
|
const anySoulHint = `spawn any listed soul: oats spawn <soul> --dir ${shortPath(dir)}${soulNames.length ? ` (e.g. ${soulNames.slice(0, 3).join(", ")})` : ""}`;
|
|
2915
3020
|
// A member's clone goes beside oats-local.yaml under its repo name; `agents/` is the instance
|
|
2916
|
-
// homes, so a member called "agents" is cloned as `agents-repo/` (design doc §4).
|
|
3021
|
+
// homes, so a member called "agents" is cloned as `agents-repo/` (design doc §4). The HOST is
|
|
3022
|
+
// listed exactly like any other member when it is one: a clone of it is needed only if someone
|
|
3023
|
+
// works IN it — there is no special "workspace clone" (discovery reads the host over the remote).
|
|
2917
3024
|
const cloneDirOf = (m) => join(dir, m.name === "agents" ? "agents-repo" : m.name);
|
|
2918
|
-
const
|
|
3025
|
+
const clonePresent = (m, cloneDir) => {
|
|
3026
|
+
const declared = ctx.local?.clones && typeof ctx.local.clones === "object" ? ctx.local.clones[m.key] : undefined;
|
|
3027
|
+
const candidate = typeof declared === "string" && declared.trim() ? resolve(dir, declared) : cloneDir;
|
|
3028
|
+
return existsSync(join(candidate, ".git")) ? candidate : null;
|
|
3029
|
+
};
|
|
3030
|
+
const clones = members.filter((m) => m.confirmed || (standalone && m.key === synced.discovery.key)).map((m) => {
|
|
3031
|
+
const cloneDir = cloneDirOf(m);
|
|
3032
|
+
const present = clonePresent(m, cloneDir);
|
|
3033
|
+
return { key: m.key, name: m.name, url: memberUrlOf(synced.discovery, m.key), dir: present ?? cloneDir, present: present !== null, host: m.key === synced.discovery.key };
|
|
3034
|
+
});
|
|
2919
3035
|
// Decision 26: the host publishes the member list to whoever can read it. When the host is
|
|
2920
3036
|
// itself a member (the common `agents` shape) that is fine for an all-private or all-public
|
|
2921
3037
|
// organisation; a mixed one needs a private host that is NOT a public member. The kernel
|
|
@@ -2925,20 +3041,18 @@ async function onboardCmd() {
|
|
|
2925
3041
|
const result = { onboardApi: 2, standalone: standalone || undefined, local: localFile, dir, agents: join(dir, "agents"), lock: synced.lockFile, sync: synced.report, hosting, next: { clone: clones, spawn: spawnHint, souls: soulNames.slice(0, 3) } };
|
|
2926
3042
|
if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
|
|
2927
3043
|
|
|
2928
|
-
console.log(`Onboarded ${shortPath(dir)} into workspace ${workspaceName(synced.discovery)} (${synced.discovery.key} @ ${short(synced.discovery.commit)}).${standalone ? `\n (
|
|
3044
|
+
console.log(`Onboarded ${shortPath(dir)} into workspace ${workspaceName(synced.discovery)} (${synced.discovery.key} @ ${short(synced.discovery.commit)}).${standalone ? `\n (${standaloneNote(synced.discovery, { from: " from here" })})` : ""}\n`);
|
|
2929
3045
|
printSyncReport(ctx, synced);
|
|
2930
3046
|
console.log(`
|
|
2931
|
-
|
|
2932
|
-
${shortPath(dir)}/
|
|
3047
|
+
This directory (${shortPath(dir)}) is your deployment — any layout works; it now holds what the kernel needs:
|
|
2933
3048
|
├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
|
|
2934
3049
|
├── oats-lock.json exact commit + integrity + per-version executable approval per package
|
|
2935
|
-
|
|
2936
|
-
|
|
3050
|
+
└── agents/ instance homes, each self-contained
|
|
3051
|
+
Member clones live wherever you keep them (here, or anywhere named in oats-local.yaml clones:).
|
|
2937
3052
|
|
|
2938
3053
|
Next:
|
|
2939
|
-
1.
|
|
2940
|
-
|
|
2941
|
-
A clone elsewhere is fine: point at it in oats-local.yaml under clones: { <repo key>: <abs path> }.
|
|
3054
|
+
1. Members you will work IN need a clone (discovery and resolution run over the remotes; only a
|
|
3055
|
+
soul's work target does):${clones.map((c) => c.present ? `\n ${shortPath(c.dir)} ✓ already here${c.host ? " (the host — a member like the others)" : ""}` : `\n git clone ${c.url ?? c.key} ${shortPath(c.dir)} (or point oats-local.yaml clones: { ${c.key}: <abs path> } at an existing clone)${c.host ? "\n ↑ the host is a member like the others: clone it only if someone works IN it — the workspace file is read over the remote" : ""}`).join("") || "\n (no confirmed members yet — see the membership rows above)"}${!hostIsMember ? `\n (the host ${synced.discovery.key} is not a member: nothing to clone — the workspace file is read over the remote)` : ""}
|
|
2942
3056
|
2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
|
|
2943
3057
|
names every member, so if any member is private the host must be a private repo that is not
|
|
2944
3058
|
a public member; public contributors then get the standalone case (from: here + oats.core).
|
|
@@ -3001,18 +3115,27 @@ function createCmd() {
|
|
|
3001
3115
|
* oats <namespace> <command> [args…] — run a command an active capability
|
|
3002
3116
|
* declares in its manifest (`commands: { name: "script args" }`).
|
|
3003
3117
|
* Kernel subcommands take precedence over capability namespaces.
|
|
3118
|
+
*
|
|
3119
|
+
* Three contexts, one contract (OATS_CAPABILITY / OATS_SETTINGS / OATS_CLI_BIN):
|
|
3120
|
+
* - inside an instance home: the home's materialized modules (instance.json.modules);
|
|
3121
|
+
* - from a v2 DEPLOYMENT (oats-local.yaml in reach, no home): operator-level
|
|
3122
|
+
* dispatch — resolve exactly as `oats spawn --soul <x>` would, fetch the
|
|
3123
|
+
* namespace's capability into <deployment>/.oats/modules/<cap>@<commit12>/
|
|
3124
|
+
* and run THAT copy with the soul's merged payload (lib/operator-dispatch.mjs;
|
|
3125
|
+
* contracts doc, "Post-0.25.0 clarifications");
|
|
3126
|
+
* - otherwise the classic config chain.
|
|
3004
3127
|
*/
|
|
3005
|
-
function capabilityCommand() {
|
|
3128
|
+
async function capabilityCommand() {
|
|
3006
3129
|
// JSON-aware boundary: in --json mode every dispatch failure — inactive or
|
|
3007
3130
|
// untrusted capability, duplicate namespace, unknown subcommand, broken
|
|
3008
3131
|
// metadata/manifests, malformed command values — must still emit exactly
|
|
3009
3132
|
// one envelope object on stdout. The WHOLE dispatcher runs inside the
|
|
3010
3133
|
// boundary; only "no namespace matched" escapes (returns false to the help
|
|
3011
3134
|
// fallthrough).
|
|
3012
|
-
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
3135
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
3013
3136
|
const NOT_DISPATCHED = Symbol("not-dispatched");
|
|
3014
3137
|
let outcome;
|
|
3015
|
-
try { outcome = dispatch(); }
|
|
3138
|
+
try { outcome = await dispatch(); }
|
|
3016
3139
|
catch (e) {
|
|
3017
3140
|
// Unexpected throw from discovery/trust/decoding: keep the envelope contract.
|
|
3018
3141
|
bail("E_CAPABILITY_BROKEN", e.message || e);
|
|
@@ -3020,7 +3143,23 @@ function capabilityCommand() {
|
|
|
3020
3143
|
}
|
|
3021
3144
|
return outcome !== NOT_DISPATCHED;
|
|
3022
3145
|
|
|
3023
|
-
|
|
3146
|
+
/** Operator-level dispatch from a deployment directory (no instance home). */
|
|
3147
|
+
async function operatorDispatch() {
|
|
3148
|
+
let hit;
|
|
3149
|
+
try {
|
|
3150
|
+
const { resolveOperatorDispatch } = await import("../lib/operator-dispatch.mjs");
|
|
3151
|
+
let catalog = null; try { catalog = officialPackageCatalog(); } catch { /* the lock carries url for catalog packages */ }
|
|
3152
|
+
hit = await resolveOperatorDispatch(process.cwd(), cmd, flag("soul"), { remoteOptions: remoteOptionsFromEnv(), catalog });
|
|
3153
|
+
} catch (e) {
|
|
3154
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details);
|
|
3155
|
+
throw e;
|
|
3156
|
+
}
|
|
3157
|
+
if (!hit) return NOT_DISPATCHED;
|
|
3158
|
+
const teamCtx = hit.soul?.team ? { name: hit.soul.team } : undefined;
|
|
3159
|
+
return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree);
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
async function dispatch() {
|
|
3024
3163
|
let activeIds;
|
|
3025
3164
|
let context = process.cwd();
|
|
3026
3165
|
let teamCtx;
|
|
@@ -3032,6 +3171,7 @@ function capabilityCommand() {
|
|
|
3032
3171
|
// namespace the operator typed on the command line.
|
|
3033
3172
|
let capSettings = Object.create(null);
|
|
3034
3173
|
let instanceModules = false;
|
|
3174
|
+
let deployment = null;
|
|
3035
3175
|
try {
|
|
3036
3176
|
if (metaFile && existsSync(metaFile)) {
|
|
3037
3177
|
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
@@ -3043,12 +3183,19 @@ function capabilityCommand() {
|
|
|
3043
3183
|
// spawned before a team: block was declared have no snapshot.
|
|
3044
3184
|
teamCtx = meta.team || resolveOatsConfig(context).team;
|
|
3045
3185
|
} else {
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3186
|
+
// Not inside a home: a v2 deployment (oats-local.yaml in reach) resolves
|
|
3187
|
+
// through the workspace, exactly as a spawn of --soul would (below).
|
|
3188
|
+
try { const { deploymentOf } = await import("../lib/operator-dispatch.mjs"); deployment = deploymentOf(context); }
|
|
3189
|
+
catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
|
|
3190
|
+
if (!deployment) {
|
|
3191
|
+
const resolved = resolveOatsConfig(context, flag("soul"));
|
|
3192
|
+
activeIds = resolved.capabilities.map((c) => c.id);
|
|
3193
|
+
for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
|
|
3194
|
+
teamCtx = resolved.team;
|
|
3195
|
+
}
|
|
3050
3196
|
}
|
|
3051
3197
|
} catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
|
|
3198
|
+
if (deployment) return operatorDispatch();
|
|
3052
3199
|
// Workspace model: an instance's own materialized modules are the command
|
|
3053
3200
|
// namespaces available to it (instance.json.modules → <home>/.oats/modules).
|
|
3054
3201
|
const mans = Object.values(capabilityManifests(instanceModules ? instanceHome : context)).filter((m) => m.command === cmd && m.commands);
|
|
@@ -3058,6 +3205,14 @@ function capabilityCommand() {
|
|
|
3058
3205
|
if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
|
|
3059
3206
|
const trust = capabilityTrust(m, context);
|
|
3060
3207
|
if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
|
|
3208
|
+
return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir);
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
/** Help / unknown-command / spec validation / exec — shared by every context.
|
|
3212
|
+
* `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
|
|
3213
|
+
* resolves the directory holding the executable — the operator branch fetches
|
|
3214
|
+
* the module tree only when a command is actually going to run). */
|
|
3215
|
+
async function runManifestCommand(m, settings, teamCtx, ensureDir) {
|
|
3061
3216
|
const sub = args[1];
|
|
3062
3217
|
const cmds = Object.keys(m.commands);
|
|
3063
3218
|
// `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
|
|
@@ -3083,17 +3238,22 @@ function capabilityCommand() {
|
|
|
3083
3238
|
const spec = m.commands[sub];
|
|
3084
3239
|
if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: manifest command must be a non-empty string (got ${JSON.stringify(spec)})`);
|
|
3085
3240
|
const [script, ...rest] = spec.trim().split(/\s+/);
|
|
3241
|
+
let dir;
|
|
3242
|
+
try { dir = await ensureDir(); }
|
|
3243
|
+
catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
|
|
3244
|
+
const withDir = { ...m, _dir: dir };
|
|
3086
3245
|
let abs;
|
|
3087
|
-
try { abs = capabilityExecutablePath(
|
|
3246
|
+
try { abs = capabilityExecutablePath(withDir, script); }
|
|
3088
3247
|
catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
|
|
3089
|
-
if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(
|
|
3248
|
+
if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(dir, script)})`);
|
|
3090
3249
|
const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
|
|
3091
3250
|
...process.env, OATS_CAPABILITY: m.capability,
|
|
3092
3251
|
// Package-runtime boundary: dispatched commands receive the active
|
|
3093
|
-
// capability's EFFECTIVE settings (instance snapshot
|
|
3094
|
-
//
|
|
3095
|
-
//
|
|
3096
|
-
|
|
3252
|
+
// capability's EFFECTIVE settings (instance snapshot, resolved context, or
|
|
3253
|
+
// the soul's merged payload on operator-level dispatch), same contract as
|
|
3254
|
+
// lifecycle hooks — capabilities read their settings here instead of
|
|
3255
|
+
// importing the kernel resolver.
|
|
3256
|
+
OATS_SETTINGS: JSON.stringify(settings || {}),
|
|
3097
3257
|
// PATH is not a trusted runtime boundary (maintainer finding 1): pass the
|
|
3098
3258
|
// canonical absolute executable of THIS CLI; official consumers execFile
|
|
3099
3259
|
// it directly and never resolve `oats` from PATH or a shell.
|
|
@@ -3675,7 +3835,7 @@ else if (cmd && Object.hasOwn(REMOVED_VERBS, cmd)) {
|
|
|
3675
3835
|
console.log(usageText());
|
|
3676
3836
|
process.exit(1);
|
|
3677
3837
|
}
|
|
3678
|
-
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
|
|
3838
|
+
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && await capabilityCommand()) { /* dispatched */ }
|
|
3679
3839
|
// No matching kernel command or capability namespace: in --json mode the help
|
|
3680
3840
|
// text must NOT contaminate stdout — still one envelope object, nonzero exit.
|
|
3681
3841
|
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `unknown command "${cmd}" — no kernel subcommand or active capability namespace matches`);
|
|
@@ -3832,12 +3992,15 @@ Usage:
|
|
|
3832
3992
|
oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
|
|
3833
3993
|
optionally run the update; then run oats doctor
|
|
3834
3994
|
oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
|
|
3835
|
-
|
|
3995
|
+
[--approve <id>@<version>]... oats-local.yaml over its Git remote, confirm every
|
|
3836
3996
|
member (reciprocal oats-membership.yaml), resolve
|
|
3837
3997
|
packages: to exact commits, ask executable approval
|
|
3838
3998
|
once per package version (TTY; otherwise list what
|
|
3839
3999
|
needs it and exit 2), write oats-lock.json (v3) and
|
|
3840
|
-
report the diff
|
|
4000
|
+
report the diff. --approve approves exactly that
|
|
4001
|
+
package at exactly that locked version without a
|
|
4002
|
+
terminal (the digest is computed over the locked
|
|
4003
|
+
tree; an id@version not in the lock is E_BAD_ARGS)
|
|
3841
4004
|
oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
|
|
3842
4005
|
| remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
|
|
3843
4006
|
print the line to add (the file travels through Git)
|
package/docs/configuration.md
CHANGED
|
@@ -21,7 +21,7 @@ converted: [rebuild-to-v2.md](rebuild-to-v2.md).
|
|
|
21
21
|
schemaVersion: 2
|
|
22
22
|
workspace: git:github.com/acme/agents # REQUIRED — the workspace host, observed over the remote
|
|
23
23
|
|
|
24
|
-
clones: # optional — member clones
|
|
24
|
+
clones: # optional — member clones that are not beside oats-local.yaml under their repo name
|
|
25
25
|
github.com/acme/platform: /Users/ana/src/acme-platform
|
|
26
26
|
|
|
27
27
|
settings: # optional — host-owned values per capability
|
|
@@ -41,7 +41,7 @@ refused (`E_WORKSPACE_SCHEMA`).
|
|
|
41
41
|
| key | meaning |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `workspace` | Repo ref of the workspace host (`git:host/org/repo`, `https://…`, `git@host:…`, `file:///…`, `/abs/bare.git`). Read with your own Git credentials; the repo need not be cloned. |
|
|
44
|
-
| `clones` | `<canonical repo key>: <absolute path>` — where a member's clone lives when it is not at `<deployment>/<
|
|
44
|
+
| `clones` | `<canonical repo key>: <absolute path>` — where a member's clone lives when it is not at `<deployment>/<member name>/`. Only a soul's **work target** (`work: worktree \| checkout`) needs a clone. Lookup order: `spawn --repo`, then this map (keys normalised through `parseRepoRef`, so any ref spelling of the same repo matches), then `<deployment>/<member name>` (a member named `agents` → `<deployment>/agents-repo`, since `agents/` is the instance root); none → `E_CLONE_MISSING`; a directory whose `origin` is another repo → `E_CLONE_MISMATCH`. |
|
|
45
45
|
| `settings.<cap>.<key>` | Host-owned provider values the capability's manifest asks for — absolute paths, state roots, delivery modes. The workspace file **refuses** absolute paths; this is where they go. Merged into the capability's provider payload after the soul's own payload and before any `--provider` flag (see [three homes](workspaces.md#provider-payloads-have-three-homes)). |
|
|
46
46
|
| `souls.disabled` | Soul names not run on this machine; reported by `oats sync` ("disabled here"). |
|
|
47
47
|
|
|
@@ -56,7 +56,7 @@ deployment. Not found → `E_LOCAL_MISSING`. Beside it:
|
|
|
56
56
|
~/acme-workspace/
|
|
57
57
|
├── oats-local.yaml
|
|
58
58
|
├── oats-lock.json # written by `oats sync` (lock v3; docs/packages.md)
|
|
59
|
-
├── agents/ # instance homes + fetched member-soul sources
|
|
59
|
+
├── agents/ # instance homes + fetched member-soul sources (created by `oats sync` / `oats onboard` if absent)
|
|
60
60
|
└── <member clones>/ # only where someone works IN a repo
|
|
61
61
|
```
|
|
62
62
|
|