@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 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, executablesDigest, readPackageTree,
40
- classifyPackageValue, manifestExecutables, parsePackageRequest,
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 tree = await readPackageTree(remoteModule, req.remoteRef, entry.commit, entry.path, { remoteOptions });
1946
- const targets = tree.manifests.flatMap((m) => manifestExecutables(m.manifest).map((x) => `${m.name}: ${x.kind} ${x.name} → ${x.target}`));
1947
- return { digest: executablesDigest(tree), targets };
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 answer = (await rl.question(question)).trim().toLowerCase();
1955
- return answer === "y" || answer === "yes";
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 ?? `(standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)`} (${discovery.key} @ ${short(discovery.commit)})`);
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(` (standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)\n`);
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 ? ` — standalone: the workspace of ${memberLabel(discovery.key)} cannot be read` : ""}\n`);
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). Offline → { unreachable }.
2194
- * Returns null when the deployment is not a workspace deployment (no oats-local.yaml). */
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 = data.some((a) => (a.instances || []).some((i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length));
2199
- if (!hasModules) return { drift: new Map(), unreachable: null };
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
- if (!i.modules || typeof i.modules !== "object" || !Object.keys(i.modules).length) continue;
2215
- try { drift.set(i.home ?? `${a.name}/${i.instance}`, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ }
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) for (const i of a.instances || []) { const rows = ws.drift.get(i.home ?? `${a.name}/${i.instance}`); 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 } : {}) })); }
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.repo || "?"}]`);
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 rows = ws?.drift.get(i.home ?? `${a.name}/${i.instance}`) || [];
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 taught `<name>-workspace/` layout
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 clones = members.filter((m) => m.confirmed || (standalone && m.key === synced.discovery.key)).map((m) => ({ key: m.key, name: m.name, url: memberUrlOf(synced.discovery, m.key), dir: cloneDirOf(m) }));
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 (standalone — the workspace of ${memberLabel(synced.discovery.key)} cannot be read from here; you get its own souls + oats.core)` : ""}\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
- Layout (the taught convention — the kernel finds clones through oats-local.yaml, so any layout works):
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
- ├── agents/ instance homes, each self-contained
2936
- └── <member>/ clones of the members you will work IN (only those)
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. Clone the members you will work IN beside oats-local.yaml (discovery and resolution run over the
2940
- remotes; only a soul's work target needs a clone):${clones.map((c) => `\n git clone ${c.url ?? c.key} ${shortPath(c.dir)}`).join("") || "\n (no confirmed members yet — see the membership rows above)"}
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
- function dispatch() {
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
- const resolved = resolveOatsConfig(context, flag("soul"));
3047
- activeIds = resolved.capabilities.map((c) => c.id);
3048
- for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
3049
- teamCtx = resolved.team;
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(m, script); }
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(m._dir, script)})`);
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 or resolved context),
3094
- // same contract as lifecycle hooks — capabilities read their settings
3095
- // here instead of importing the kernel resolver.
3096
- OATS_SETTINGS: JSON.stringify(capSettings[m.capability] || {}),
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
- oats-local.yaml over its Git remote, confirm every
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)
@@ -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 outside the <name>-workspace/ convention
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>/<repo-name>/`. Only a soul's **work target** needs a clone. |
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