@awebai/oats 0.25.1 → 0.25.3

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
@@ -1948,15 +1948,39 @@ async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOpti
1948
1948
  return { digest, targets };
1949
1949
  }
1950
1950
 
1951
- /** 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). */
1952
1954
  async function askYesNo(question) {
1953
1955
  const rl = createInterface({ input: process.stdin, output: process.stderr });
1954
1956
  try {
1955
- const answer = (await rl.question(question)).trim().toLowerCase();
1956
- 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";
1957
1962
  } finally { rl.close(); }
1958
1963
  }
1959
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
+
1960
1984
  /** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
1961
1985
  * way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
1962
1986
  * `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
@@ -1983,6 +2007,16 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1983
2007
  let lock = resolved.lock;
1984
2008
  const approvalNeeded = [];
1985
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 = [];
1986
2020
  for (const id of Object.keys(lock.packages)) {
1987
2021
  const entry = lock.packages[id];
1988
2022
  if (entry.approved) continue;
@@ -1992,6 +2026,7 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1992
2026
  if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1993
2027
  throw e;
1994
2028
  }
2029
+ if (approveWanted.has(id)) { lock = approvePackage(lock, id, digest); approvedNow.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets }); continue; }
1995
2030
  if (interactive) {
1996
2031
  console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
1997
2032
  for (const t of targets) console.error(` ${t}`);
@@ -2002,12 +2037,27 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
2002
2037
  }
2003
2038
  let lockFile;
2004
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 */ }
2005
2043
  const members = memberRows(discovery);
2006
2044
  const packages = packageRows(lock);
2007
2045
  const changes = resolved.changes;
2008
2046
  const items = workspaceItems(discovery, lock, { includePrivate: true });
2009
- 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 };
2010
- 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`;
2011
2061
  }
2012
2062
 
2013
2063
  /** The human §8 report of a sync (text mode). */
@@ -2015,7 +2065,7 @@ function printSyncReport(ctx, synced) {
2015
2065
  const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
2016
2066
  const { members, packages, changes } = report;
2017
2067
  const disabled = new Set(ctx.local.souls?.disabled || []);
2018
- 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)})`);
2019
2069
  console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
2020
2070
  console.log(`packages ${packages.map((p) => {
2021
2071
  const need = approvalNeeded.find((a) => a.id === p.id);
@@ -2034,6 +2084,7 @@ function printSyncReport(ctx, synced) {
2034
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); }
2035
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)"}`);
2036
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})`);
2037
2088
  if (approvalNeeded.length) {
2038
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).`);
2039
2090
  } else console.log(`\nlock ${shortPath(lockFile)}`);
@@ -2157,7 +2208,7 @@ async function workspaceCmd() {
2157
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 };
2158
2209
  if (JSON_MODE) { jsonOk(result); return; }
2159
2210
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
2160
- 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`);
2161
2212
  console.log("Members:");
2162
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 ?? "?"}` : "—"]));
2163
2214
  for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
@@ -2180,7 +2231,7 @@ async function itemsCmd(kind) {
2180
2231
  const items = workspaceItems(discovery, lock)[kind];
2181
2232
  const standalone = discovery.standalone === true;
2182
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; }
2183
- 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`);
2184
2235
  if (!items.length) console.log(" (none)");
2185
2236
  else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
2186
2237
  else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
@@ -2191,13 +2242,25 @@ async function itemsCmd(kind) {
2191
2242
  // ---------- roster: status / spawn / retire / create ----------
2192
2243
  /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
2193
2244
  * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
2194
- * the members' current state (and package modules to the lock). Offline → { unreachable }.
2195
- * 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. */
2196
2250
  async function statusDrift(data) {
2197
2251
  let ctx;
2198
2252
  try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
2199
- const hasModules = data.some((a) => (a.instances || []).some((i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length));
2200
- 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 };
2201
2264
  const deploymentDir = dirname(ctx.path);
2202
2265
  let lock = null;
2203
2266
  try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
@@ -2207,15 +2270,22 @@ async function statusDrift(data) {
2207
2270
  try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
2208
2271
  catch (e) {
2209
2272
  const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
2210
- 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) } };
2211
2274
  }
2212
- const { driftOf } = await import("../lib/materialize.mjs");
2275
+ const { driftOf, soulDriftOf } = await import("../lib/materialize.mjs");
2213
2276
  const drift = new Map();
2277
+ const soul = new Map();
2214
2278
  for (const a of data) for (const i of a.instances || []) {
2215
- if (!i.modules || typeof i.modules !== "object" || !Object.keys(i.modules).length) continue;
2216
- 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;
2217
2287
  }
2218
- return { drift, unreachable: null };
2288
+ return { drift, soul, souls, unreachable: null };
2219
2289
  }
2220
2290
  /** One `modules:` line per module. */
2221
2291
  function driftLine(row) {
@@ -2226,6 +2296,20 @@ function driftLine(row) {
2226
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"}`}]`;
2227
2297
  return base;
2228
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
+ }
2229
2313
  const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
2230
2314
 
2231
2315
  async function status() {
@@ -2237,18 +2321,31 @@ async function status() {
2237
2321
  const ws = await statusDrift(data);
2238
2322
  const verbose = args.includes("--verbose");
2239
2323
  if (args.includes("--json")) {
2240
- 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
+ }
2241
2335
  console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
2242
2336
  }
2243
2337
  console.log(`oats status — agents root ${shortPath(root)}\n`);
2244
2338
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
2245
2339
  if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
2246
2340
  for (const a of data) {
2247
- 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)}]`);
2248
2342
  if (a.description) console.log(` ${a.description}`);
2249
2343
  for (const i of a.instances) {
2250
2344
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2251
- 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) || [];
2252
2349
  for (const r of rows) if (verbose || r.status !== "current") console.log(` ${driftLine(r)}`);
2253
2350
  }
2254
2351
  for (const f of a.retireFailures || []) {
@@ -2502,13 +2599,20 @@ async function spawnCmd() {
2502
2599
  // package = locked+approved) and copied whole into the new home. Without one
2503
2600
  // (a bare agents root, tests) the classic soul-directory spawn proceeds.
2504
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;
2505
2607
  if (wsPrepared) {
2506
2608
  try {
2507
- const { toCapabilityRows, modulesPreview } = await import("../lib/instance-resolution.mjs");
2609
+ const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
2508
2610
  prepared = wsPrepared;
2509
2611
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
2510
2612
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
2511
2613
  prepared.toCapabilityRows = toCapabilityRows;
2614
+ const effectiveWork = requestedWork || agent.work || "checkout";
2615
+ if (effectiveWork === "worktree" || effectiveWork === "checkout") preparedRepo = requireMemberClone(prepared, { explicit: repo });
2512
2616
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2513
2617
  }
2514
2618
  let r;
@@ -2521,7 +2625,7 @@ async function spawnCmd() {
2521
2625
  ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
2522
2626
  // Directory execution uses deployment configuration, not an ambient Git
2523
2627
  // checkout (especially when invoked via --dir from a source instance).
2524
- repo: (requestedWork || agent.work) === "directory"
2628
+ repo: preparedRepo !== undefined ? preparedRepo : (requestedWork || agent.work) === "directory"
2525
2629
  ? (repo ?? agent.repo) : repo || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2526
2630
  work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
2527
2631
  launchConfig: valueFlag("launch-config"),
@@ -2823,7 +2927,7 @@ async function paneCmd() {
2823
2927
 
2824
2928
  /** `oats onboard [<dir>] --workspace <repo ref> [--json]` — workspace model v2 (decision 9).
2825
2929
  *
2826
- * 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)
2827
2931
  * (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
2828
2932
  * `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
2829
2933
  * instance homes), then runs exactly the `oats sync` path — discover over the
@@ -2914,9 +3018,20 @@ async function onboardCmd() {
2914
3018
  const spawnHint = setupExpert ? `oats spawn oats-setup-expert --dir ${shortPath(dir)}` : null;
2915
3019
  const anySoulHint = `spawn any listed soul: oats spawn <soul> --dir ${shortPath(dir)}${soulNames.length ? ` (e.g. ${soulNames.slice(0, 3).join(", ")})` : ""}`;
2916
3020
  // A member's clone goes beside oats-local.yaml under its repo name; `agents/` is the instance
2917
- // 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).
2918
3024
  const cloneDirOf = (m) => join(dir, m.name === "agents" ? "agents-repo" : m.name);
2919
- 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
+ });
2920
3035
  // Decision 26: the host publishes the member list to whoever can read it. When the host is
2921
3036
  // itself a member (the common `agents` shape) that is fine for an all-private or all-public
2922
3037
  // organisation; a mixed one needs a private host that is NOT a public member. The kernel
@@ -2926,20 +3041,18 @@ async function onboardCmd() {
2926
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) } };
2927
3042
  if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
2928
3043
 
2929
- 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`);
2930
3045
  printSyncReport(ctx, synced);
2931
3046
  console.log(`
2932
- Layout (the taught convention — the kernel finds clones through oats-local.yaml, so any layout works):
2933
- ${shortPath(dir)}/
3047
+ This directory (${shortPath(dir)}) is your deployment — any layout works; it now holds what the kernel needs:
2934
3048
  ├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
2935
3049
  ├── oats-lock.json exact commit + integrity + per-version executable approval per package
2936
- ├── agents/ instance homes, each self-contained
2937
- └── <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:).
2938
3052
 
2939
3053
  Next:
2940
- 1. Clone the members you will work IN beside oats-local.yaml (discovery and resolution run over the
2941
- 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)"}
2942
- 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)` : ""}
2943
3056
  2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
2944
3057
  names every member, so if any member is private the host must be a private repo that is not
2945
3058
  a public member; public contributors then get the standalone case (from: here + oats.core).
@@ -3879,12 +3992,15 @@ Usage:
3879
3992
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
3880
3993
  optionally run the update; then run oats doctor
3881
3994
  oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
3882
- oats-local.yaml over its Git remote, confirm every
3995
+ [--approve <id>@<version>]... oats-local.yaml over its Git remote, confirm every
3883
3996
  member (reciprocal oats-membership.yaml), resolve
3884
3997
  packages: to exact commits, ask executable approval
3885
3998
  once per package version (TTY; otherwise list what
3886
3999
  needs it and exit 2), write oats-lock.json (v3) and
3887
- 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)
3888
4004
  oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
3889
4005
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3890
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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Purpose:** the one accurate view of every work stream in the redesign, what is on main, what is in flight, who owns it, and what blocks it. Lead: `oats-expert` (redesign lead). Updated whenever anything merges, is returned, or reality changes. Older per-lane boards are superseded by this file.
4
4
 
5
- **Last update:** 2026-09-23 17:40Z · **Workspace model v2 phases A–C ON MAIN (`59ae22df`, PR99) → **`v0.25.0` PUBLISHED** (bump `dc333e4e`)** · Phase D next (framework repos as the first workspace; six expert souls; `oats.core`/`oats.setup` rewrite) · ⏸ parity pipeline still paused except 10B-0 (→ 0.24.14 when the engineer hands off; note: 0.24.14 must be cut from the 0.24 line, not main).
5
+ **Last update:** 2026-09-23 20:00Z · **0.25.0 + 0.25.1 + 0.25.2 PUBLISHED** (workspace model A–C + team-review fixes + operator-rebuild round) · Phase D plan next · **Desktop engineer paused by the human** (10B-0 uncommitted foundation preserved; resume is the human's call; 0.24.14 to be cut from a maintenance branch off `e5cdaf95` when its PR lands) · parity pipeline ⏸.
6
6
 
7
7
  Legend: ✅ on main/published · 🔄 in flight (PR/branch) · 🟡 preserved, not adopted · ⬜ not started · ⛔ blocked
8
8
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Status:** Phase1 implementation authorised by the human, using the existing developers under lead supervision/review. The workspace home is confirmed as `oats`; `oats-dev` remains development capabilities. This does not claim completed conversion or authorise unspecified new contracts, credential operations or live deployment mutations.
6
6
 
7
- > **2026-09-23 — direction change, read first.** Phases 1–3 delivered as written (workspace on Git, five souls + central knowledge, all contract-bearing Desktop parity slices, releases 0.24.7–0.24.13). Reviewing the result, the human judged the *declaration model* itself too heavy and, in one sitting, accepted a simplified **workspace model v2**: one workspace per org; reciprocal membership as the only gate and as the trust decision; a soul says `from:` (member repo | `here` | `package`) — a location, never a version; packages are the only versioned thing; **nothing is installed** — every capability is copied whole into the instance at spawn; discovery over Git remotes; teams as labels; harnesses start normally. Record: `agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md`; worked example: [2026-09-23-simplified-workspace-model.md](2026-09-23-simplified-workspace-model.md). **Consequences for this plan:** P1.3's `oats.yaml` export indexes and P1.4's `oats-config.yaml` activation are superseded (→ `oats-membership.yaml`, derived activation); D1/D4 stand (`oats.core` becomes `from: package` by default; the catalog stays as discovery); D2's "explicit `oats.core` on every soul" is met by the workspace default; D3 gains the `<name>-workspace/` convention and clone-then-spawn. **No migration** — clean v2, the framework's own repos are the first workspace. A Phase 4 implementation plan is proposed to the human before any `lib/`/`bin/` change; the Desktop parity pipeline is paused meanwhile except for the in-flight 10B-0 security fix.
7
+ > **2026-09-23 — direction change, read first.** Phases 1–3 delivered as written (workspace on Git, five souls + central knowledge, all contract-bearing Desktop parity slices, releases 0.24.7–0.24.13). Reviewing the result, the human judged the *declaration model* itself too heavy and, in one sitting, accepted a simplified **workspace model v2**: one workspace per org; reciprocal membership as the only gate and as the trust decision; a soul says `from:` (member repo | `here` | `package`) — a location, never a version; packages are the only versioned thing; **nothing is installed** — every capability is copied whole into the instance at spawn; discovery over Git remotes; teams as labels; harnesses start normally. Record: `agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md`; worked example: [2026-09-23-simplified-workspace-model.md](2026-09-23-simplified-workspace-model.md). **Consequences for this plan:** P1.3's `oats.yaml` export indexes and P1.4's `oats-config.yaml` activation are superseded (→ `oats-membership.yaml`, derived activation); D1/D4 stand (`oats.core` becomes `from: package` by default; the catalog stays as discovery); D2's "explicit `oats.core` on every soul" is met by the workspace default; D3 gains the `the operator's deployment directory` convention and clone-then-spawn. **No migration** — clean v2, the framework's own repos are the first workspace. A Phase 4 implementation plan is proposed to the human before any `lib/`/`bin/` change; the Desktop parity pipeline is paused meanwhile except for the in-flight 10B-0 security fix.
8
8
 
9
9
  ## Goal and order
10
10
 
@@ -1,6 +1,6 @@
1
1
  # A simpler multi-repo model for OATS — brainstorm with a worked example
2
2
 
3
- **Status:** **ACCEPTED DIRECTION** — every question closed with the human on 2026-09-23; nothing implemented yet. This document is the worked example; the normative record is the Decision concept `agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md` and the adoption plan. Implementation is a clean v2 (no migration), planned separately. **Decided so far (human, 2026-09-23):** (1) reciprocal membership stays a hard gate; (2) workspace-tier trust = membership, nothing more; (3) everything in a member is discoverable by default — a soul or capability that wants to stay internal says `private: true` in its own definition; (4) the repo's half of the handshake is a one-line **`oats-membership.yaml`** (replaces `oats.yaml`); (5) **no migration path** — a clean break to v2; (6) a soul names each capability **with where it comes from** (`from: <member repo>` or `from: package`) — a location, never a version; resolution is a handshake check + lookup, not a search; (7) **full per-instance materialization** — every capability (skills, injects, scripts, hooks) is copied into the instance at spawn; a running instance never changes under itself; (8) **nothing is "installed"** — a package is just a second kind of source, fetched at the pinned version and copied in like a member's capability; the lock records exact commit/integrity and the one-time executable approval per version; (9) **discovery and resolution work against Git remotes, never local clones** — the only thing that needs a clone is a soul's work target; the local layout is the operator's, with a taught convention (`<name>-workspace/`); (10) the handshake is *observed* with the operator's own read access to both halves — no access to the workspace repo means no membership from that seat, by design; (11) **members carry no revision** — a member is always its latest state; a team that wants frozen capabilities publishes them as a package and pins that; (12) **one workspace per org, teams are labels** — `team:` on a soul/capability (or a repo default in `oats-membership.yaml`) organises and can supply additive defaults, but never gates, restricts or partitions anything; (13) **harnesses start normally and resolve skills from their own default places** — OATS contributes materialized capability skills into the instance's `.agents/skills/` and stops excluding machine-level or repo-level skills. **All questions closed; ready to be written up as a Decision.**
3
+ **Status:** **ACCEPTED DIRECTION** — every question closed with the human on 2026-09-23; nothing implemented yet. This document is the worked example; the normative record is the Decision concept `agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md` and the adoption plan. Implementation is a clean v2 (no migration), planned separately. **Decided so far (human, 2026-09-23):** (1) reciprocal membership stays a hard gate; (2) workspace-tier trust = membership, nothing more; (3) everything in a member is discoverable by default — a soul or capability that wants to stay internal says `private: true` in its own definition; (4) the repo's half of the handshake is a one-line **`oats-membership.yaml`** (replaces `oats.yaml`); (5) **no migration path** — a clean break to v2; (6) a soul names each capability **with where it comes from** (`from: <member repo>` or `from: package`) — a location, never a version; resolution is a handshake check + lookup, not a search; (7) **full per-instance materialization** — every capability (skills, injects, scripts, hooks) is copied into the instance at spawn; a running instance never changes under itself; (8) **nothing is "installed"** — a package is just a second kind of source, fetched at the pinned version and copied in like a member's capability; the lock records exact commit/integrity and the one-time executable approval per version; (9) **discovery and resolution work against Git remotes, never local clones** — the only thing that needs a clone is a soul's work target; the local layout is the operator's — onboarding asks for the directory, no named convention; (10) the handshake is *observed* with the operator's own read access to both halves — no access to the workspace repo means no membership from that seat, by design; (11) **members carry no revision** — a member is always its latest state; a team that wants frozen capabilities publishes them as a package and pins that; (12) **one workspace per org, teams are labels** — `team:` on a soul/capability (or a repo default in `oats-membership.yaml`) organises and can supply additive defaults, but never gates, restricts or partitions anything; (13) **harnesses start normally and resolve skills from their own default places** — OATS contributes materialized capability skills into the instance's `.agents/skills/` and stops excluding machine-level or repo-level skills. **All questions closed; ready to be written up as a Decision.**
4
4
  **Date:** 2026-09-23
5
5
  **Author:** oats-expert, from a conversation with the human about the weight of the current declaration files.
6
6
 
@@ -419,10 +419,10 @@ Northwind adopts it through `external:` with a pinned revision. There is deliber
419
419
 
420
420
  **The only thing that needs a local clone is a soul's work target** — a soul with `work: worktree | checkout | directory` works *in* a repo, so that repo must be on disk. Spawning a soul whose repo is not yet cloned is therefore a *guided clone into the conventional place, then spawn*: a job for the onboarding skill (`oats-setup-expert`), not the kernel.
421
421
 
422
- The **taught default** — what onboarding sets up and what the docs show — is one folder named after the workspace with the member clones inside it:
422
+ **The deployment directory is the operator's choice** (decision 9): an existing folder that already holds the member clones is the usual case; onboarding asks for it (`oats onboard <dir>`) and adds the three entries the kernel needs. Shown here as Ana's `~/northwind/`, with the member clones inside it:
423
423
 
424
424
  ```
425
- ~/northwind-workspace/ ← "<name>-workspace"
425
+ ~/northwind/ ← the directory Ana chose (any name, any place)
426
426
  ├── oats-local.yaml ← which workspace this machine realizes + host paths + disabled souls
427
427
  ├── oats-lock.json ← exact commit + integrity + per-version approval per package
428
428
  ├── agents/ ← instance homes, each self-contained
@@ -565,7 +565,7 @@ OATS is a **skill contributor, not a skill sandbox**. Today the harness is launc
565
565
  - **Duplicate names** (team review): two *composed* capability skills with the same name are still a spawn error naming both (`skill-overrides` picks one). A composed skill and an ambient repo/machine skill with the same name is **not** an error — the harness's precedence decides; the preview lists composed names so a clash is visible.
566
566
 
567
567
  ```
568
- ~/northwind-workspace/agents/release-manager/instances/release-manager-v3/
568
+ ~/northwind/agents/release-manager/instances/release-manager-v3/
569
569
  ├── AGENTS.md ← canonical, composed: soul AGENTS.md + capability injects
570
570
  ├── CLAUDE.md → AGENTS.md ← relative symlink (unchanged from today)
571
571
  ├── .agents/skills/ ← canonical; where Pi (and Codex) look
@@ -328,8 +328,8 @@ chain); the interim is to run the module binary directly with `OATS_SETTINGS`
328
328
  and `OATS_CLI_BIN`, as the tarball smoke does.
329
329
 
330
330
  **`work: workspace` is kept.** A coordination soul's `./work` is the deployment
331
- boundary — the directory holding `oats-local.yaml` (the taught
332
- `<name>-workspace/`, with member clones beside it) — read-only across member
331
+ boundary — the directory holding `oats-local.yaml` (whatever the operator
332
+ named it, member clones beside it or named in `clones:`) — read-only across member
333
333
  clones, no branch recorded. The clone map in `oats-local.yaml` (`clones:`) is
334
334
  how such a soul finds a member whose clone is elsewhere. **Status: the
335
335
  directory link is the intent; 0.25.0's kernel still derives the boundary from
@@ -458,3 +458,129 @@ session recompose` refuses a **module home** (`instance.json.modules` present)
458
458
  with `E_UNSUPPORTED_MODE` ("re-spawn"); the `session-recompose` feature name
459
459
  stays advertised because the verb still serves classic homes
460
460
  (`docs/desktop-cli-api.md`).
461
+
462
+ ### 0.25.2 operator-rebuild round (2026-09-24) — appended, not edited in place
463
+
464
+ Source: an operator's first rebuild of a real two-team deployment on 0.25.0,
465
+ following `docs/rebuild-to-v2.md` literally. **The guide is a contract the
466
+ kernel must honour**: where the guide claimed behaviour the kernel lacked, the
467
+ kernel changes; where the guide described keys no provider consumes, the guide
468
+ changes. Findings R1–R10; kernel side in 0.25.2 (`docs/release-notes/v0.25.2.md`).
469
+ No API integer or feature name changes; the only surface additions are
470
+ additive fields (`spawn --preview` `providers` / `settings`, `oats status
471
+ --json instances[].soul`) and the `sync --approve` flag.
472
+
473
+ **§2/§5 member clone lookup (R1).** For a `work: worktree | checkout` soul the
474
+ kernel finds the member clone in this order, first hit wins: (1) `oats spawn
475
+ --repo <abs path>`; (2) `oats-local.yaml` `clones: { <repo key>: <abs path> }`,
476
+ keys normalised through `parseRepoRef(...).key` so any spelling of the same
477
+ repo addresses one entry; (3) the convention `<deployment>/<member name>` where
478
+ `<member name>` is the last segment of the repo key — **a member named `agents`
479
+ is looked for at `<deployment>/agents-repo`** (`<deployment>/agents/` is the
480
+ instance root); (4) none → `E_CLONE_MISSING { repoKey, tried: [...], remedies }`
481
+ naming the three remedies. A directory found by (2) or (3) whose `origin` remote
482
+ resolves to a different repo key → `E_CLONE_MISMATCH { repoKey, path, origin }`
483
+ — the kernel never spawns into a clone that is not the member. This order was
484
+ stated by the guide and `docs/workspaces.md` before 0.25.2 and not implemented;
485
+ it is now normative.
486
+
487
+ **§6 `oats sync` creates `agents/` (R2).** `sync` (and therefore `onboard`,
488
+ which runs the sync body) creates `<deployment>/agents/` when absent. A
489
+ hand-written `oats-local.yaml` needs no `mkdir`.
490
+
491
+ **§5 one "You run on OATS" block (R3).** When `oats.core` resolves as a module
492
+ the composer suppresses the kernel's legacy `oats:kernel:oats` block; the
493
+ module's inject is the one such block. Without `oats.core` (a soul saying `off`)
494
+ the legacy block is composed as before, so no instance is left without the
495
+ briefing.
496
+
497
+ **§5/§6 soul-source drift (R4).** `driftOf` covers `instance.json.workspace.soul`
498
+ as well as `modules`: `oats status` prints `soul: <name> from <member> @ <c7>`
499
+ with `[member moved since …]` when the member's default branch is past the
500
+ recorded commit (`[member unconfirmed]` / `[soul no longer present]` for the
501
+ missing cases); `--json` adds `instances[].soul = { repoKey, commit, current:
502
+ <commit>|null, status: "current"|"moved"|"missing" }`. A moved soul is
503
+ information (decision 17): the instance keeps its own commit directory (M1).
504
+
505
+ **§6 preview payload visibility (R5).** `oats spawn --preview` (text and
506
+ `--json`) reports `providers` — the `--provider <cap> k=v` map exactly as given,
507
+ nested — and `settings.<cap>` — `resolution.payloads[cap]`, the merged payload
508
+ the provider's binding receives (`workspace.messaging` base ⊕ `byTeam[team]` ⊕
509
+ soul slot payload ⊕ `local.settings[cap]` ⊕ `providers[cap]`). Additive fields;
510
+ both empty objects when nothing applies.
511
+
512
+ **§5/§6 `work: workspace` (R6, closed in 0.25.1 as B2).** Documented in the
513
+ guide's §9: a coordination soul's `./work` is the deployment directory.
514
+
515
+ **Provider payload delivery vs provider consumption (R7 — oats.aweb 1.11.2).**
516
+ Decision 23 (`messaging.byTeam`) is **kernel semantics**: the kernel merges and
517
+ delivers; the provider consumes what its binding declares. oats.aweb 1.11.2's
518
+ spawn hook (a) locates the aweb root among `OATS_TEAM_SCOPE`, the home, the
519
+ home's git root, `OATS_CONTEXT` and its git root, and `OATS_WORKSPACE` (under
520
+ v2: the deployment directory) — none of which is a 0.24 team root; and (b)
521
+ resolves the target team from `OATS_TEAM_ID`/`OATS_TEAM_NAME` (the removed
522
+ `oats-config.yaml` `team:` block; empty under v2), else the **active team at
523
+ the root it found** — it does **not** read `team` from `OATS_SETTINGS`. So for
524
+ 1.11.2 `byTeam` is delivered and recorded but a no-op; per-label minting is
525
+ obtained only by placing a per-team `.aw` inside each team's member clone
526
+ (gitignored) so it is found through the work repo, or one `.aw` at the
527
+ deployment directory for a single team. The guide states this (§8b) and
528
+ `docs/workspaces.md` states the general rule ("kernel-merged; whether a
529
+ provider honours it is the provider's"). **oats.aweb follow-up**: read `team`
530
+ (and honour `byTeam`'s result) from the payload; accept the deployment
531
+ directory as a first-class root. The kernel does not paper over this with a
532
+ `team:` env shim — the env block is removed with `oats-config.yaml`, and a
533
+ provider contract is the provider's to grow.
534
+
535
+ **OKF 2.1.3 reads `okf.json`, not a soul payload (R8 — corrects §2's `stores`
536
+ comment and decision 24's `root` example).** `oats.okf` 2.1.3's spawn hook
537
+ reads the soul's knowledge declaration from `<soul>/okf.json` (`{ version: 1,
538
+ owner, owns: ["<base>/<node>"], reads: [...] }`, `lib/config.mjs#validateDeclaration`)
539
+ and its settings from `OATS_SETTINGS`, admitting **only** `bindings-file`,
540
+ `state-dir`, `harvest-runtime`, `harvest-model` (`oats.json#settings`) — any
541
+ other key is `E_CONFIG unknown OATS_SETTINGS property`. Where a base lives
542
+ inside a store repository is the **bindings file's** `bases.<alias>.repository`
543
+ + `root`, not a soul payload key. Therefore: a soul.yaml `knowledge:` payload for
544
+ OKF carries binding settings only (usually nothing — the workspace default
545
+ fills the slot; `none` opts out); `owns`/`reads`/`store`/`root` examples on
546
+ `soul.yaml` are removed from the guide, `workspaces.md`, `souls-and-instances.md`
547
+ and `knowledge.md`; `okf.json` stays in `souls/<name>/` and travels with the
548
+ soul into the per-commit cache (M1). A soul-payload grammar for OKF is an OKF
549
+ follow-up that lands with an `oats.okf` release declaring it in its binding.
550
+ The kernel's part — opaque forwarding of the merged payload — is unchanged and
551
+ correct. §7b's fresh `state-dir` rule is confirmed by the operator's run.
552
+
553
+ **§6 non-interactive approval (R9).** `oats sync --approve <id>@<version>`
554
+ (repeatable) approves exactly the entry the current resolution contains for
555
+ that id and version: the executables digest is always computed by `sync` over
556
+ the fetched tree (`executablesDigestAt`) and recorded — never typed. An
557
+ `--approve` naming an id/version the resolution does not contain → `E_BAD_ARGS`
558
+ (nothing approved); entries not covered stay unapproved (exit `2`). At the
559
+ interactive prompt **Ctrl+D (EOF) is a decline**: exit `2`, entry unapproved —
560
+ never treated as "yes", never a hang.
561
+
562
+ **§6 onboard next steps (R10).** `oats onboard` lists the workspace **host**
563
+ in `next.clone` like any member that lacks a clone at the convention (the host
564
+ is a member; a soul that lives in it may need a work clone). Under an explicit
565
+ `oats-local.yaml` `standalone:` header the next steps say the view is standalone
566
+ and list only that repo.
567
+
568
+ ### 0.25.3 — `OATS_SOUL_ID` (stable soul identity for providers)
569
+
570
+ The per-commit soul cache (0.25.1, M1) made `realpath(<home>/soul)` change with every
571
+ member commit; a provider that keyed durable state on that path (OKF 2.1.3 `owners.json`)
572
+ refused the next spawn (`E_OWNER`). "Members are latest" and "the owner is a path" cannot
573
+ both hold, so the kernel now hands hooks a **stable identity**:
574
+
575
+ - `OATS_SOUL_ID` in the `spawn` / `retire` / `launch` hook environment: for a workspace soul
576
+ `<repo key>#<soul name>` exactly as the canonical key is spelled (e.g.
577
+ `github.com/awebai/aweb#aweb-protocol-expert`, local fixtures `local//abs/path.git#name`);
578
+ for a classic soul the realpath of `agents/<name>/soul` (today's value — 0.24 deployments
579
+ unchanged). Also recorded as `instance.json.workspace.soul.id`.
580
+ - `OATS_SOUL` is the **content** the home links — for a workspace soul the per-commit
581
+ directory `agents/<name>/souls/<commit12>/`, never the swappable `agents/<name>/soul`
582
+ pointer. Providers read content from `OATS_SOUL` and key state on `OATS_SOUL_ID`.
583
+ - Provider contract (OKF 2.1.4): `owners[owner] = OATS_SOUL_ID ?? realpath(OATS_SOUL ?? home/soul)`;
584
+ a prior row whose value is a path under `agents/<same soul name>/(soul|souls/<commit>)` is
585
+ migrated to the id once, not refused; any other mismatch stays `E_OWNER`.
586
+