@awebai/oats 0.25.1 → 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 +151 -35
- package/docs/configuration.md +3 -3
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +1 -1
- package/docs/design/2026-09-23-simplified-workspace-model.md +4 -4
- package/docs/design/2026-09-23-workspace-module-contracts.md +108 -2
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +3 -3
- package/docs/desktop-cli-api.md +1 -1
- package/docs/desktop.md +1 -1
- package/docs/first-team.md +3 -3
- package/docs/knowledge.md +14 -7
- package/docs/rebuild-to-v2.md +182 -26
- package/docs/release-notes/v0.25.2.md +80 -0
- package/docs/souls-and-instances.md +34 -16
- package/docs/workspaces.md +65 -26
- package/lib/core.mjs +33 -4
- package/lib/instance-resolution.mjs +132 -2
- package/lib/materialize.mjs +29 -0
- package/package.json +1 -1
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
|
|
1956
|
-
|
|
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 ?? `(
|
|
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(` (
|
|
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 ? ` —
|
|
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)
|
|
2195
|
-
*
|
|
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 =
|
|
2200
|
-
|
|
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
|
-
|
|
2216
|
-
try { drift.set(
|
|
2279
|
+
const key = i.home ?? `${a.name}/${i.instance}`;
|
|
2280
|
+
if (hasModules(i)) { try { drift.set(key, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ } }
|
|
2281
|
+
if (hasSoul(i)) { try { const row = soulDriftOf(i, discovery); if (row) soul.set(key, row); } catch { /* an unreadable soul record shows as no soul row */ } }
|
|
2282
|
+
}
|
|
2283
|
+
// The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
|
|
2284
|
+
for (const [, stamp] of souls) {
|
|
2285
|
+
const member = discovery.members.find((m) => m && m.key === stamp.repoKey);
|
|
2286
|
+
if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
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.
|
|
2941
|
-
|
|
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
|
-
|
|
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)
|
package/docs/configuration.md
CHANGED
|
@@ -21,7 +21,7 @@ converted: [rebuild-to-v2.md](rebuild-to-v2.md).
|
|
|
21
21
|
schemaVersion: 2
|
|
22
22
|
workspace: git:github.com/acme/agents # REQUIRED — the workspace host, observed over the remote
|
|
23
23
|
|
|
24
|
-
clones: # optional — member clones
|
|
24
|
+
clones: # optional — member clones that are not beside oats-local.yaml under their repo name
|
|
25
25
|
github.com/acme/platform: /Users/ana/src/acme-platform
|
|
26
26
|
|
|
27
27
|
settings: # optional — host-owned values per capability
|
|
@@ -41,7 +41,7 @@ refused (`E_WORKSPACE_SCHEMA`).
|
|
|
41
41
|
| key | meaning |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `workspace` | Repo ref of the workspace host (`git:host/org/repo`, `https://…`, `git@host:…`, `file:///…`, `/abs/bare.git`). Read with your own Git credentials; the repo need not be cloned. |
|
|
44
|
-
| `clones` | `<canonical repo key>: <absolute path>` — where a member's clone lives when it is not at `<deployment>/<
|
|
44
|
+
| `clones` | `<canonical repo key>: <absolute path>` — where a member's clone lives when it is not at `<deployment>/<member name>/`. Only a soul's **work target** (`work: worktree \| checkout`) needs a clone. Lookup order: `spawn --repo`, then this map (keys normalised through `parseRepoRef`, so any ref spelling of the same repo matches), then `<deployment>/<member name>` (a member named `agents` → `<deployment>/agents-repo`, since `agents/` is the instance root); none → `E_CLONE_MISSING`; a directory whose `origin` is another repo → `E_CLONE_MISMATCH`. |
|
|
45
45
|
| `settings.<cap>.<key>` | Host-owned provider values the capability's manifest asks for — absolute paths, state roots, delivery modes. The workspace file **refuses** absolute paths; this is where they go. Merged into the capability's provider payload after the soul's own payload and before any `--provider` flag (see [three homes](workspaces.md#provider-payloads-have-three-homes)). |
|
|
46
46
|
| `souls.disabled` | Soul names not run on this machine; reported by `oats sync` ("disabled here"). |
|
|
47
47
|
|
|
@@ -56,7 +56,7 @@ deployment. Not found → `E_LOCAL_MISSING`. Beside it:
|
|
|
56
56
|
~/acme-workspace/
|
|
57
57
|
├── oats-local.yaml
|
|
58
58
|
├── oats-lock.json # written by `oats sync` (lock v3; docs/packages.md)
|
|
59
|
-
├── agents/ # instance homes + fetched member-soul sources
|
|
59
|
+
├── agents/ # instance homes + fetched member-soul sources (created by `oats sync` / `oats onboard` if absent)
|
|
60
60
|
└── <member clones>/ # only where someone works IN a repo
|
|
61
61
|
```
|
|
62
62
|
|
|
@@ -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
|
|
5
|
+
**Last update:** 2026-09-23 20:00Z · **0.25.0 + 0.25.1 PUBLISHED** (workspace model A–C + team-review fixes) · 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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
332
|
-
|
|
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,109 @@ 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.
|
|
@@ -28,9 +28,9 @@ Each is one PR (or two small ones), reviewed by me, on `main`, as canonical code
|
|
|
28
28
|
| **W7** | **CLI switch-over** | `oats sync`, `oats spawn` (preview/apply unchanged in shape, now fed by W4/W6), `oats capabilities` / `oats souls` with origin + team, `oats workspace status`; `oats status` shows `modules … @ commit` + `member moved since`; `oats version --json` advertises `workspaceApi: 2` + feature `workspace-v2`; **remove the v1 readers** (`oats.yaml`, per-soul `source:`, `oats-config.yaml` activation) — a v1 file at a v2 path errors naming the schema | lead | M |
|
|
29
29
|
| **W8** | **Delete v1** | Remove `portable-*`, `source-spec`, `capability-provenance` (v1 parts), `prepared-resources`, migration stores/evidence, the installed tier in `packages.mjs`, classic config activation, their tests and docs; `core.mjs` loses everything that only served v1 | lead | L (mostly deletion) |
|
|
30
30
|
| **W9** | **Framework repos as the first workspace (decisions 18–21)** | `oats-workspace.yaml` v2 in `oats` (drop the six imports; `packages:` pins oats.framework/okf/aweb/jira/linear/authoring/dev **as packages**; `teams:` global/engineering; `defaults`); `oats-membership.yaml` in ALL seven repos; the six soul editions rewritten (`oats.okf: {from: package}` etc. even though the repos are members); **a new expert soul in every package repo** — `okf-expert`, `aweb-expert`, `jira-expert`, `linear-expert`, `authoring-expert`, `dev-expert` (v2 `souls/<name>/`, team global, knows and evolves that capability); `package-catalog.json` kept as the official marketplace (bare-version `packages:` entries resolve through it) | lead (member-repo PRs to their owners; the expert souls' AGENTS.md drafted by me, reviewed by the package owner) | L |
|
|
31
|
-
| **W9b** | **`oats.core` and `oats.setup` rewritten for the new architecture (human, 2026-09-23)** | The two official capabilities' skills and injects are rewritten to teach the *new* model, not patched: **`oats.core`** (`oats-operate`, `oats-souls`, the "you run on OATS" inject) — how an agent works inside an instance under this architecture: its home layout (`.oats/modules/`, `.agents/skills/`, `instance.json` modules/providers), the verbs it will actually use (`status`, `spawn --preview/--provider`, `retire`, `session`, `instance events`, `capabilities`, `souls`, `workspace status`), what a workspace/member/package/team is *from the agent's seat*, drift ("member moved since"), how to find and read other souls. **`oats.setup`** (`oats-config`/`oats-packages` → renamed to what they now are: `oats-workspace`, `oats-packages`, `oats-onboarding`) — the whole architecture and its best practices: workspace ↔ repo handshake and why, `oats-workspace.yaml` / `oats-membership.yaml` / `soul.yaml` v2 / `oats-local.yaml` field by field, teams as labels, member-tier vs package-tier and the non-collapse rule, `packages:` + lock v3 + per-version approval, the official catalog vs `git:` refs, `sync`/`package add`, the
|
|
32
|
-
| **W10** | **Docs** | **`docs/rebuild-to-v2.md` — the rebuild guide (ships with the schemas; states that 0.24.x keeps spawning 0.24.x deployments)**; `docs/workspaces.md` rewritten around v2; `souls-and-instances.md`, `packages.md`, `configuration.md` (mostly deleted), `first-team.md` → the
|
|
33
|
-
| **W11** | **Onboarding skill** | `oats-setup-expert` / `oats.setup`:
|
|
31
|
+
| **W9b** | **`oats.core` and `oats.setup` rewritten for the new architecture (human, 2026-09-23)** | The two official capabilities' skills and injects are rewritten to teach the *new* model, not patched: **`oats.core`** (`oats-operate`, `oats-souls`, the "you run on OATS" inject) — how an agent works inside an instance under this architecture: its home layout (`.oats/modules/`, `.agents/skills/`, `instance.json` modules/providers), the verbs it will actually use (`status`, `spawn --preview/--provider`, `retire`, `session`, `instance events`, `capabilities`, `souls`, `workspace status`), what a workspace/member/package/team is *from the agent's seat*, drift ("member moved since"), how to find and read other souls. **`oats.setup`** (`oats-config`/`oats-packages` → renamed to what they now are: `oats-workspace`, `oats-packages`, `oats-onboarding`) — the whole architecture and its best practices: workspace ↔ repo handshake and why, `oats-workspace.yaml` / `oats-membership.yaml` / `soul.yaml` v2 / `oats-local.yaml` field by field, teams as labels, member-tier vs package-tier and the non-collapse rule, `packages:` + lock v3 + per-version approval, the official catalog vs `git:` refs, `sync`/`package add`, the deployment directory (the operator's; no naming convention) and clone-then-spawn, private items, external souls, the standalone case, provider payload homes (soul/machine/spawn), what is deliberately NOT versioned and why. Every skill is validated against the shipped CLI (`oats <verb> --help` snapshot test) so they cannot drift from the commands. | lead (swarm + review) | M |
|
|
32
|
+
| **W10** | **Docs** | **`docs/rebuild-to-v2.md` — the rebuild guide (ships with the schemas; states that 0.24.x keeps spawning 0.24.x deployments)**; `docs/workspaces.md` rewritten around v2; `souls-and-instances.md`, `packages.md`, `configuration.md` (mostly deleted), `first-team.md` → the operator's deployment directory; DTO doc § Workspace v2; release notes | lead | M |
|
|
33
|
+
| **W11** | **Onboarding skill** | `oats-setup-expert` / `oats.setup`: ASK for the deployment directory (an existing folder with the operator's clones is the usual case — no named convention, decision 9), `oats sync`, clone-then-spawn; **the hosting rule for mixed public/private organisations (decision 26): ask up front whether any member is private; if so the workspace file is hosted in a private repo that is NOT a public member (a dedicated private `workspace` repo is the honest shape), public contributors get the standalone case with `oats.core` by default (decision 25); `oats onboard` prints the same rule in its next-steps**; the `oats-operate` skill updated for the new verbs | lead | S |
|
|
34
34
|
| **W12** | **Desktop follow-through** | New kernel DTOs (from W7) consumed the usual way — engineer reads the merged head, files pins, wires: Capabilities/Souls origin + team columns; "installed" removed as a state; spawn preview shows modules (from/commit/hash) + team; Workspaces surface shows membership status | Desktop engineer, after W7 | M |
|
|
35
35
|
|
|
36
36
|
**Team review (Antares, 2026-09-23) folded in:** rebuild guide (W10), **second review (two aweb teams, stores, soul layout, standalone, public/private hosting → decisions 23–26: `messaging.byTeam`, stores = repo + provider `root`, `souls/` only, `oats.core` standalone default, private host rule taught by onboarding),** `--provider` instance payload (W6/W7), drift display (W7), duplicate-name rule (W6), 0.24.x-keeps-working stated everywhere.
|