@awebai/oats 0.38.2 → 0.39.1

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.
@@ -0,0 +1,99 @@
1
+ # OATS 0.39.0
2
+
3
+ ## Changed (breaking)
4
+
5
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.40.0`**, so it runs against
6
+ this release's kernel. Install the CLI and the Desktop 0.39.0 together: the
7
+ Desktop 0.38.x refuses a 0.39 CLI.
8
+
9
+ ## Added
10
+
11
+ - **`oats server connect`: put this workspace on another machine and register
12
+ it, in one run** (awebai/oats#517, feature `server-connect`). From a
13
+ deployment, `oats server connect <id> --ssh <host>` takes a machine you reach
14
+ over ssh, even one with no OATS and no deployment, to a registered, synced
15
+ deployment of the same workspace. Six steps (`ssh`, `oats`, `git`,
16
+ `deployment`, `register`, `readiness`) are re-checked on every run, and each
17
+ does only what is missing. `--install-oats` installs exactly this kernel's
18
+ version with npm on the host. The deployment goes to `~/Agents/<this
19
+ deployment's directory name>` there unless `--dir` says otherwise; a
20
+ non-empty directory is never written into. What only a person can do (Git
21
+ credentials on the host, a provider's configuration) comes back as
22
+ `needs-human` with the exact remedy; run the same command again afterwards.
23
+ See docs/servers.md, "Connect a machine".
24
+
25
+ - **A registration knows which workspace it serves** (feature
26
+ `servers-per-workspace`). `~/.oats/servers.json` registrations record
27
+ `workspaceKey`, the canonical workspace key the host's deployment reports.
28
+ It is learned from the host, never typed: `server add` records it when the
29
+ host answers, `server check` fills it in when it is missing, and a host that
30
+ reports a different key is refused with `E_SERVER_WORKSPACE_MISMATCH`
31
+ without rewriting the registration. `oats server list --workspace-ref <ref>`
32
+ lists one workspace's servers and reports `unknownWorkspace`.
33
+
34
+ - **Capability commands run on a server with `--server <id>`** (feature
35
+ `capability-route`). For example, `oats aweb setup --join aweb
36
+ --invite-stdin --soul dev --server altair-aweb` runs `oats aweb setup …` in
37
+ the registered deployment on that machine, with stdin forwarded untouched
38
+ and the host's output, exit status and `--json` envelope relayed. Invite
39
+ values are redacted wherever the command line is printed.
40
+
41
+ - **macOS hosts say why a private remote is unreadable over ssh.** On macOS,
42
+ in a session without a terminal (every routed command runs in one), Git
43
+ cannot open the login keychain where its credential helper keeps the forge
44
+ token. An `auth` failure there now carries `details.hint:
45
+ "keychain-non-interactive"` and names the remedies: store Git credentials
46
+ outside the keychain (`gh auth login --insecure-storage`, then `gh auth
47
+ setup-git`), or use an SSH key the session can reach (one without a
48
+ passphrase, or the desktop login's ssh-agent with `SSH_AUTH_SOCK` pointed at
49
+ it in the shell's startup file). `oats server check` reports whether the
50
+ host's Git reads the workspace remote (`workspaceReadable`).
51
+
52
+ - **A capability command from a deployment no longer needs `--soul`**
53
+ (feature `operator-default-soul`). Without it, `oats <namespace> …` runs as
54
+ the first soul of the deployment, by name, not disabled there, that provides
55
+ the namespace, and says which on stderr. An explicit `--soul` still wins.
56
+ When no soul provides the namespace the command is refused with `E_BAD_ARGS`
57
+ naming it. This is what lets `oats aweb setup … --server <id>` and the
58
+ Desktop's `oats aweb connect` run without naming a soul.
59
+
60
+ - `oats onboard <dir> [--workspace <ref>] --check --json` answers, without
61
+ writing anything, where `<dir>` is, what is there, and whether this
62
+ machine's Git reads the workspace remote. `server connect` asks it of the
63
+ host.
64
+
65
+ - **The Desktop shows each workspace's own machines** (awebai/oats#517). With
66
+ a CLI that has `servers-per-workspace` and `server-connect`, a window's
67
+ spawn dialog (**Where to run**) and Workspace › Setup (**Machines**) list
68
+ only the machines whose registration reports that window's workspace key,
69
+ and **Add a machine to this workspace…** runs `oats server connect` (then
70
+ `oats aweb connect` for oats.aweb messaging), showing each step and what to
71
+ do for any that needs you. Without those features the Desktop behaves as
72
+ before. See docs/desktop.md.
73
+
74
+ - **oats.aweb 1.21.0: `oats aweb connect <server-id>` gives a deployment on
75
+ another machine its messaging** (awebai/oats#517). After `oats server
76
+ connect` has created the workspace's deployment on a registered server, one
77
+ command from the local deployment gives that deployment membership in its
78
+ default team. It runs through the kernel's capability route
79
+ (`oats aweb … --server <id>`) and reports its steps (`aw`, `invite`, `join`,
80
+ `readiness`) in the server-connect status vocabulary, with every runnable
81
+ remedy in backticks. It checks aw on the host and installs it with
82
+ `--install-aw`. It mints a hosted invite from this deployment's root for the
83
+ host's default team; nothing is minted when the host is already a member.
84
+ It joins on the host with `oats aweb setup --join <label> --invite-stdin`,
85
+ then checks again. On this machine and in transit the invite token exists
86
+ only in memory and on the routed command's stdin; on the host,
87
+ `aw id team accept-invite` takes it as an argument, so it is visible in the
88
+ host's process list while that call runs. The join half works on its own: `setup --invite-stdin`,
89
+ `setup --install-aw [--aw-version <v>]` and `setup --check-only --json`.
90
+ `setup --join` also recovers a per-team root left connected but unrecorded
91
+ by an interrupted join.
92
+
93
+ ## Upgrading
94
+
95
+ - Install the CLI and the Desktop 0.39 together. An older kernel on the same
96
+ machine refuses a `servers.json` holding `workspaceKey` (an unknown field),
97
+ so don't downgrade once 0.39 has written registrations; removing the
98
+ `workspaceKey` lines by hand makes the file readable to an older kernel
99
+ again.
@@ -0,0 +1,42 @@
1
+ # OATS 0.39.1
2
+
3
+ ## Changed
4
+
5
+ - **oats.aweb 1.21.1: a Claude Code start says that it will wait at Claude
6
+ Code's development-channels confirmation** (awebai/oats-aweb#44). Under
7
+ `delivery: channel`, Claude Code loads the `aweb-channel` plugin with
8
+ `--dangerously-load-development-channels`, because the plugin is not on
9
+ Claude Code's approved channel list. Claude Code then stops at its "Loading
10
+ development channels" confirmation before the session starts, and waits
11
+ until someone answers it in the instance's terminal. Unattended starts
12
+ (Desktop starts and restarts, automations, successors) waited there with
13
+ nothing saying so. Now:
14
+ - every spawn or start that adds the flag carries the warning
15
+ `channel-dev-confirmation` once;
16
+ - `oats readiness` reports it, without changing the status, for a home
17
+ whose last start was Claude Code under `channel`.
18
+
19
+ Nothing answers the confirmation on the human's behalf. Launch arguments
20
+ and settings are unchanged. `--channels` registers only plugins on Claude
21
+ Code's approved list; for a plugin not on it, Claude Code prints a startup
22
+ warning and the channel does not register. That list is Anthropic's
23
+ default, or a Team/Enterprise organization's managed
24
+ `allowedChannelPlugins`, which replaces the default and requires
25
+ `channelsEnabled: true`.
26
+
27
+ ## Fixed
28
+
29
+ - **`oats readiness` no longer fails package souls on membership**
30
+ (awebai/oats#533). A package soul (`oats.okf/knowledge-maintainer`,
31
+ `oats.engineering/code-reviewer`, …) failed its `member` check with
32
+ "member <its package's repository>: not a member of the workspace", and the
33
+ remedy asked to list the package's repository in `members:`. A package
34
+ soul is trusted through the workspace's `packages:` pin, never through
35
+ membership (see [the non-collapse rule](../workspaces.md#member-tier-vs-package-tier-the-non-collapse-rule)).
36
+ Its `member` item is now `package <package>/<soul>`. It passes when the
37
+ package is declared and locked, ships the soul at the locked commit, and
38
+ the soul's copy matches its digest: for `--soul`, the copy a spawn would
39
+ link against the lock; for `--home`, the home's copy against the digest it
40
+ recorded, and the lock against that same digest while it still pins the
41
+ same commit. A mismatch fails with `E_PACKAGE_INTEGRITY`. Member and
42
+ external souls are unchanged. Spawn already applied this rule.
package/docs/servers.md CHANGED
@@ -10,11 +10,14 @@ keeps a saved route per remote instance.
10
10
 
11
11
  ```bash
12
12
  oats server add build --ssh build-host --workspace /srv/team --oats /usr/local/bin/oats
13
- oats server check build # ssh reachability, remote version, workspace roster; changes nothing
14
- oats server list
13
+ oats server check build # ssh reachability, remote version, workspace roster and Git readability
14
+ oats server list [--workspace-ref <ref>]
15
15
  oats server remove build
16
16
  ```
17
17
 
18
+ To put a workspace on a machine that has no deployment yet, use
19
+ [`oats server connect`](#connect-a-machine) instead of `server add`.
20
+
18
21
  - `--ssh` is an OpenSSH host alias or host name, never `user@host` or an
19
22
  option. Users, keys and host verification belong in `~/.ssh/config`.
20
23
  Connections are non-interactive (`BatchMode=yes`): a prompt fails fast.
@@ -31,7 +34,117 @@ oats server remove build
31
34
  is ignored, never written or printed.
32
35
  - `--label` sets a display name. `--replace` overwrites an existing id.
33
36
  - Registrations live in `~/.oats/servers.json` on this machine, never in a
34
- repository.
37
+ repository. Every change to the file (add, remove, a learned workspace key,
38
+ connect) is a short read-modify-write of the file as it is then, under the
39
+ lock directory `~/.oats/servers.lock`, so concurrent commands never lose each
40
+ other's registrations. A lock still held after 5 s, or left by a process
41
+ that died, is refused with `E_SERVERS_BUSY`, naming the directory to remove
42
+ once no oats process is changing the registry.
43
+
44
+ **Which workspace a server serves.** A registration records `workspaceKey`:
45
+ the canonical key of the workspace its host deployment realizes, as the
46
+ host's own `oats status --json` reports it (`workspace.key`). It is learned,
47
+ never typed:
48
+
49
+ - `server add` asks the host after writing the registration. A host that does
50
+ not answer one (unreachable, no deployment at `--workspace`, a kernel before
51
+ 0.38) is registered without it, with a warning.
52
+ - `server check` records it when the registration has none, and `server
53
+ connect` writes it.
54
+ - A key is recorded only on the registration that was asked: if the
55
+ registration is removed or pointed elsewhere while the host is being asked,
56
+ the answer is dropped (`server add` warns), never written onto the new
57
+ entry or used to bring a removed one back.
58
+ - A host reporting a different key than the recorded one is
59
+ `E_SERVER_WORKSPACE_MISMATCH` (`details: {recorded, reported}`) and the
60
+ registration is not rewritten. If the host now serves another workspace,
61
+ register it again with `server add <id> --replace`.
62
+ - `server list --workspace-ref <ref>` lists the servers whose key is the
63
+ canonical key of `<ref>` (any spelling of the same repository matches), and
64
+ `unknownWorkspace` names the ids whose key is not known yet. One host can
65
+ carry several registrations, one per deployment.
66
+
67
+ `server check` also asks the host whether its Git reads the workspace remote
68
+ (`workspaceReadable`; the host's [`onboard --check`](#what-connect-runs-on-the-host)),
69
+ which is where a [macOS keychain](#git-on-a-macos-host) problem shows up.
70
+
71
+ ## Connect a machine
72
+
73
+ ```bash
74
+ oats server connect altair-aweb --ssh altair --path /opt/homebrew/bin --install-oats
75
+ ```
76
+
77
+ Run from a deployment, `server connect` puts that deployment's workspace on
78
+ the machine behind `--ssh` and registers it, from a host with nothing but
79
+ ssh access and Node.js. Every run re-checks every step and does only what is
80
+ missing, so after a human step you run the same command again.
81
+
82
+ | Option | Default |
83
+ |---|---|
84
+ | `--workspace-ref <ref>` | this deployment's `oats-local.yaml` `workspace:` |
85
+ | `--dir <path>` | `~/Agents/<this deployment's directory name>`; absolute or `~/…`, resolved by the host |
86
+ | `--oats <path>`, `--path <dirs>`, `--label <text>` | as for `server add`; `--path` is where the host finds `npm`, `oats` and the harnesses |
87
+ | `--install-oats` | off: a missing or older OATS is a human step |
88
+ | `--replace` | off: an id registered for another host or directory is refused |
89
+
90
+ The steps, in order:
91
+
92
+ | Step | Checks | When something is missing |
93
+ |---|---|---|
94
+ | `ssh` | the host answers a non-interactive ssh | `failed` `E_SSH` |
95
+ | `oats` | `oats version --json` there is this kernel's version or newer and advertises `server-connect` (a build of the same version without it is not enough) | with `--install-oats`: runs `npm install -g @awebai/oats@<this version>` there (`done`); without it, or with no `npm` on the host's PATH: `needs-human` with the command to run |
96
+ | `git` | the host's Git reads the workspace remote | `needs-human` with the remedy (and the [keychain hint](#git-on-a-macos-host)) |
97
+ | `deployment` | `--dir` holds a deployment of this workspace | an absent or empty directory is onboarded there (`done`); a deployment of another workspace is `failed` `E_SERVER_WORKSPACE_MISMATCH`; a non-empty directory without `oats-local.yaml` is `failed` `E_DIR_NOT_EMPTY` and nothing is written into it |
98
+ | `register` | the registration exists, with its `workspaceKey` | written (`done`); an id registered for another target is `failed` `E_SERVER_EXISTS` unless `--replace` (checked before anything else, so a requested id never reports ready while it routes elsewhere); a new id whose host and directory are already registered under another id is reported (`ok`, naming that id; the text result then names that id to spawn with) and not registered twice |
99
+ | `readiness` | each soul of the host deployment (disabled souls skipped) passes `oats readiness` there | each failing or unknown required item becomes a `needs-human` line, once for all the souls that share it; the listing and the checks share one 60 s budget, and souls it does not reach (a check it cuts off included) become one line naming the command to check them; a broken link still fails the run (`E_SSH`) |
100
+
101
+ A step is `ok` (already so), `done` (this run did it), `needs-human` (its
102
+ `remedy` says what to run where; later steps are `skipped`, waiting for it)
103
+ or `failed` (the run ends). The result is `ready` when no step needs a
104
+ human. Connect never handles credentials: what Git on the host cannot read
105
+ is always a human step there. The `--json` shape is in
106
+ [desktop-cli-api.md](desktop-cli-api.md#oats-server-connect).
107
+
108
+ ### What connect runs on the host
109
+
110
+ Besides `version --json`, `status --json`, `souls --json` and `readiness
111
+ --json`, connect runs two commands there:
112
+
113
+ - `oats onboard <dir> --workspace <ref> --check --json`, read-only: where
114
+ `<dir>` is (a leading `~` is the host's home), whether it is absent, empty,
115
+ not empty, not a directory or a deployment, and whether the host's Git
116
+ reads the workspace remote. An unreadable remote is part of the answer
117
+ (`remote.readable: false` with the error), not a failure. Without
118
+ `--workspace`, a deployment's own workspace is read.
119
+ - `oats onboard <dir> --workspace <ref> --json`, only when the directory is
120
+ absent or empty. This is the only way connect onboards on a host; `onboard`
121
+ itself is not routed with `--server`.
122
+
123
+ ### Git on a macOS host
124
+
125
+ Git on macOS usually keeps the forge token in the login keychain, through
126
+ its credential helper. A session without a terminal (an ssh command, which is
127
+ how every routed command runs, or a background job) cannot open that
128
+ keychain, so reading a private remote fails as `auth` even though the same
129
+ command works in a terminal on that Mac.
130
+
131
+ When a remote read fails as `auth` on macOS in a session with no terminal on
132
+ stdin or with `SSH_CONNECTION` set, `E_REMOTE_UNREADABLE` carries
133
+ `details.hint: "keychain-non-interactive"` and `details.remedy`, and the
134
+ message names the two ways out, run once on that Mac:
135
+
136
+ ```bash
137
+ gh auth login --insecure-storage # keep the token in gh's own file, not the keychain
138
+ gh auth setup-git # let Git use gh's token
139
+ ```
140
+
141
+ or read the remote with an SSH key the session can reach. A key with a
142
+ passphrase fails in the same sessions for the same reason: an ssh session has
143
+ no `SSH_AUTH_SOCK` of its own, so it cannot reach the desktop login's
144
+ ssh-agent. Use a key without a passphrase, or point `SSH_AUTH_SOCK` at the
145
+ login agent in the shell's startup file (for zsh, `~/.zshenv`, which
146
+ non-interactive sessions read). OATS never reads or changes credentials
147
+ itself; the hint is about where Git ran, not a probe of the keychain.
35
148
 
36
149
  ## Connections
37
150
 
@@ -139,6 +252,25 @@ explicit `--dir` names a directory on the server and travels as is. Every
139
252
  other routed command refuses `--dir`; its scope comes from the
140
253
  registration.
141
254
 
255
+ **Capability commands.** Any capability command routes with `--server`:
256
+
257
+ ```bash
258
+ oats aweb setup --join aweb --invite-stdin --soul dev --server altair-aweb < invite.txt
259
+ ```
260
+
261
+ The host runs the same argv, minus `--server <id>` (or `--server=<id>`;
262
+ after a `--` the argv is the provider's and is never read), as `oats <namespace>
263
+ <command> …` from the registered workspace directory: the kernel's
264
+ capability dispatch finds the deployment from its working directory, so no
265
+ `--dir` is added to the provider's argv. `--soul` and every other flag go
266
+ through untouched. Stdin is forwarded as is and never read or logged here;
267
+ when stdin is a terminal nothing is forwarded and the host's stdin is
268
+ closed. The host's stdout, stderr and exit status are relayed; with
269
+ `--json` its envelope is relayed verbatim, and when nothing comes back this
270
+ side answers one envelope (`E_SSH`, or `E_REMOTE_ENVELOPE`). Wherever this
271
+ side prints the argv, `--invite` values are replaced by `<redacted>`.
272
+ Kernel commands keep the table above.
273
+
142
274
  **Not routed.** `session input` runs on the execution host, where schedules
143
275
  and messaging capabilities call it. `session restart --stop-grace` is refused
144
276
  with `--server`; the remote default applies. `session attach --print` shows
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A mkdir lock for short read-modify-write sections over files several oats
3
+ * processes share (the host schedule state, the server registry).
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+
9
+ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
10
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ /** A mkdir lock that is never reclaimed by another process: an existing
13
+ * lock whose owner is unreadable or dead is refused with the directory to
14
+ * remove, because the gap between mkdir and owner.json belongs to a live
15
+ * acquirer and a dead-owner reclaim races every other acquirer. The
16
+ * holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
17
+ * held after `retryMs` is refused with `busy(why)`, the caller's own error. */
18
+ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
19
+ mkdirSync(dirname(dir), { recursive: true });
20
+ const deadline = Date.now() + retryMs;
21
+ for (;;) {
22
+ try { mkdirSync(dir); break; }
23
+ catch (e) {
24
+ if (e.code !== "EEXIST") throw e;
25
+ let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
26
+ if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
27
+ throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
28
+ }
29
+ }
30
+ writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid: process.pid, at: new Date().toISOString(), what }) + "\n");
31
+ const release = () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
32
+ const onSignal = (sig) => { release(); process.exit(sig === "SIGINT" ? 130 : 143); };
33
+ process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
34
+ try { return fn(); }
35
+ finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); release(); }
36
+ }
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchFolderTrust, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
18
+ import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance, soulCopyDigests } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility } from "./resolve.mjs";
21
21
  import { discoveredTeamKeys, isRemovedTeamKeys, isTeamRefusal, recordedTeams, reportRows, soulKeyOf, teamKeyOf, soulTeams, teamModel, teamProblems } from "./teams.mjs";
@@ -133,7 +133,8 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
133
133
  kind: "instance", home: realHome, meta, deployment, agentsRoot, teams: live.teams, defaultTeam: live.defaultTeam ?? null, teamsSource: live.source, launch, launchCurrent, session: newSessionDefaults(deployment),
134
134
  recordedDefaultTeam: recordedTeams(meta).defaultTeam, teamModel: model, teamKey,
135
135
  subject: { kind: "instance", instance: meta.instance, home, soul: meta.agent },
136
- soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
136
+ soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems],
137
+ ...(obj(ws.soul?.package) && typeof ws.soul.package.id === "string" ? { package: { id: ws.soul.package.id, soul: ws.soul.name ?? meta.agent, version: ws.soul.package.version ?? null, commit: ws.soul.package.commit ?? ws.soul.commit ?? null, integrity: ws.soul.package.digest ?? null } } : {}) },
137
138
  workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
138
139
  modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
139
140
  payloads: obj(meta.providers) ? meta.providers : {},
@@ -189,7 +190,8 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
189
190
  kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, defaultTeam, teamsSource: "live", teamModel: model, teamKey, launch, session: newSessionDefaults(deployment),
190
191
  subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null },
191
192
  soul: { name: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, external: soulEntry.external === true, path: soulEntry.path ?? null,
192
- soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}) },
193
+ soulDir, definition: soulEntry.definition ?? null, problems: [], ...(soulCopyError ? { copyError: soulCopyError } : {}),
194
+ ...(typeof soulEntry.package === "string" ? { package: { id: soulEntry.package, soul: soulEntry.name, version: soulEntry.version ?? null, commit: soulEntry.commit ?? null, integrity: soulEntry.digest ?? null } } : {}) },
193
195
  workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
194
196
  modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
195
197
  payloads: obj(res?.payloads) ? res.payloads : {}, payloadOrigins: obj(res?.payloadOrigins) ? res.payloadOrigins : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
@@ -404,6 +406,48 @@ export function runProviderCheck(t, mod, dir, { timeoutMs = 30000 } = {}) {
404
406
  || (res.status === "ready" && res.problems.length)) return invalid();
405
407
  return { outcome: "result", result: { status: res.status, problems: codeMessages(res.problems), warnings: codeMessages(res.warnings ?? []) } };
406
408
  }
409
+ /** The `member` item of a package soul. A package soul is trusted through the workspace's
410
+ * `packages:` pin (the lock's commit and integrity), never through membership: its
411
+ * repository is not a member and need not be (docs/workspaces.md, the non-collapse rule).
412
+ * It passes when the discovery lists the soul from the lock (the package declared and
413
+ * locked, the soul shipped at the locked commit) and the soul's copy is intact: for a
414
+ * soul, the copy a spawn would link matches the locked digest; for a home, its copy
415
+ * matches the digest it recorded, and a lock still at that commit records the same one.
416
+ * A copy is digested every read: a complete copy is reused, never refetched. */
417
+ function packageSoulItem(t, d) {
418
+ const p = t.soul.package;
419
+ const subject = `package ${p.id}/${p.soul}`;
420
+ const from = { kind: "package", package: p.id, version: p.version, commit: p.commit, integrity: p.integrity };
421
+ const evidence = { repoKey: t.soul.repoKey, workspace: d?.key ?? null, commit: p.commit, version: p.version, integrity: p.integrity, from };
422
+ if (!d) return item(subject, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence });
423
+ if (d.standalone === true) return item(subject, "not-applicable", { required: false, producer: "workspace discovery", reason: `standalone view (${d.standaloneReason ?? "explicit"}): the workspace's packages: is not read, so the package pin is recorded, not observed`, evidence });
424
+ const shipped = (d.packageSouls || []).filter((s) => s.package === p.id);
425
+ const live = shipped.find((s) => s.name === p.soul);
426
+ if (!live) {
427
+ return item(subject, "fail", { producer: "workspace lock", evidence,
428
+ reason: shipped.length ? `the version of ${p.id} locked in this workspace does not ship the soul ${p.soul}` : `${p.id} is not locked in this workspace's packages:`,
429
+ remedy: shipped.length ? `pin a release of ${p.id} that ships ${p.soul} in the workspace's packages:, then oats sync` : `declare ${p.id} in the workspace's packages:, then oats sync` });
430
+ }
431
+ const integrity = (reason, remedy) => item(subject, "fail", { producer: "workspace lock", code: "E_PACKAGE_INTEGRITY", reason, evidence, remedy });
432
+ const lockRemedy = "oats sync (a lock that was edited: remove the entry and sync again)";
433
+ const copy = t.soul.copyError;
434
+ if (copy?.code === "E_PACKAGE_INTEGRITY") return integrity(copy.message, lockRemedy);
435
+ const dir = t.soul.soulDir;
436
+ if (!dir) return item(subject, "unknown", { producer: "workspace lock", reason: "the soul's copy is not available, so its integrity cannot be checked", evidence });
437
+ const expected = t.home ? "recorded" : "locked";
438
+ let digests;
439
+ try { digests = soulCopyDigests(dir); }
440
+ catch (e) { return integrity(`the soul's copy at ${dir} cannot be digested: ${e.message}`, `move ${dir} aside, then spawn again (a fresh copy is fetched and verified)`); }
441
+ if (!digests.includes(p.integrity)) {
442
+ return integrity(`the soul's copy at ${dir} digests ${digests.at(-1)}, not the ${expected} ${p.integrity}${t.home ? "" : " (the lock's)"}`,
443
+ t.home ? `the copy changed after its spawn, and a spawn reuses it: move ${dir} aside, then spawn a new instance of ${p.id}/${p.soul} (a fresh copy is fetched and verified against the lock)` : `move ${dir} aside, then spawn again (a fresh copy is fetched and verified against the lock); if the lock was edited: ${lockRemedy}`);
444
+ }
445
+ if (t.home && live.commit === p.commit && live.digest !== p.integrity) {
446
+ return integrity(`the lock records ${live.digest} for the same commit ${String(p.commit).slice(0, 12)}; this home recorded ${p.integrity} at its spawn`, lockRemedy);
447
+ }
448
+ return item(subject, "pass", { producer: "workspace lock", evidence });
449
+ }
450
+
407
451
  /** The total time the providers check may take in one readiness read. */
408
452
  export const PROVIDER_CHECK_BUDGET_MS = 60_000;
409
453
 
@@ -430,9 +474,11 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
430
474
  }
431
475
  configured.push(...teamItems(t), ...launchItems(t), ...folderTrustItems(t));
432
476
  // member — the soul's member repository is a confirmed member of the workspace
433
- // (oats-membership.yaml backlink observed over the remotes).
477
+ // (oats-membership.yaml backlink observed over the remotes); a package soul's is its
478
+ // package instead (packageSoulItem).
434
479
  const d = t.discovery;
435
- if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
480
+ if (t.soul.package) member.push(packageSoulItem(t, d));
481
+ else if (t.soul.external) member.push(item(`soul ${t.soul.name}`, "not-applicable", { required: false, producer: "workspace discovery", reason: "an external soul is declared by the workspace; it has no member repository", evidence: { repoKey: t.soul.repoKey } }));
436
482
  else if (!d) member.push(item(`member ${t.soul.repoKey ?? "?"}`, "unknown", { producer: "workspace discovery", reason: t.discoveryError ? `the workspace could not be read: ${t.discoveryError.message}` : "no workspace observation", evidence: { repoKey: t.soul.repoKey } }));
437
483
  // A standalone view is an allowed mode (decision 10): membership cannot be confirmed
438
484
  // there by definition, so it is not a readiness requirement (as for an external soul).
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Nothing here reads `oats-config.yaml`, an installed-capability directory or a
18
18
  * per-soul `source:` — those do not exist in this model. */
19
- import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
19
+ import { existsSync, readFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
20
20
  import { join, resolve as resolvePath, dirname, relative, isAbsolute, sep } from "node:path";
21
21
  import { oatsError } from "./errors.mjs";
22
22
  import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo, observeWorkspace } from "./workspace.mjs";
@@ -25,7 +25,7 @@ import { BY_TEAM_REMOVED, isRemovedTeamKeys, isTeamRefusal, memberNameOf, record
25
25
  import { launchLayers } from "./launch-preference.mjs";
26
26
  import { declaredSettings } from "./capability-contract.mjs";
27
27
  import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
28
- import { fetchRemoteTree } from "./remote.mjs";
28
+ import { contentDigest, fetchRemoteTree } from "./remote.mjs";
29
29
  import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
30
30
  import { tmpdir } from "node:os";
31
31
  import { spawnSync } from "node:child_process";
@@ -112,25 +112,30 @@ export function homeSoulMatches(name, meta) {
112
112
  * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
113
113
  * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
114
114
  * or an external source, `<package>/<soul>` a package soul. */
115
- export function findSoulEntry(discovery, name) {
116
- const [repoPart, soulPart] = splitSoulName(name);
117
- const hits = [];
118
- // A standalone view's one row is the repo's own (unconfirmed by definition — the
119
- // workspace could not be read); resolveSoul admits exactly that case.
115
+ /** Every soul a discovery offers, each with the rule a typed name matches it by: the confirmed
116
+ * members' souls (a standalone view's one row is the repo's own, unconfirmed by definition — the
117
+ * workspace could not be read; resolveSoul admits exactly that case), the external souls, and the
118
+ * package souls. → [{ entry, matches(name) }] */
119
+ export function soulCandidates(discovery) {
120
+ const out = [];
120
121
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
121
122
  for (const m of discovery.members || []) {
122
123
  if (!m.confirmed && m.key !== standaloneOwn) continue;
123
- for (const s of m.souls || []) {
124
- if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
125
- hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
126
- }
124
+ for (const s of m.souls || []) out.push({ entry: { ...s, repoKey: m.key, memberCommit: m.commit, external: false }, matches: (name) => soulNameMatches(name, { name: s.name, repoKey: m.key }) });
127
125
  }
128
126
  for (const x of discovery.external || []) {
129
- if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
130
- }
131
- for (const s of discovery.packageSouls || []) {
132
- if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
127
+ if (!x.soul?.name) continue;
128
+ out.push({
129
+ entry: { ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true },
130
+ matches: (name) => { const [repoPart, soulPart] = splitSoulName(name); return x.soul.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart))); },
131
+ });
133
132
  }
133
+ for (const s of discovery.packageSouls || []) out.push({ entry: { ...s, external: false }, matches: (name) => soulNameMatches(name, { name: s.name, package: s.package }) });
134
+ return out;
135
+ }
136
+
137
+ export function findSoulEntry(discovery, name) {
138
+ const hits = soulCandidates(discovery).filter((c) => c.matches(name)).map((c) => c.entry);
134
139
  if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
135
140
  if (hits.length > 1) {
136
141
  const qualified = hits.map(qualifiedSoulName);
@@ -227,6 +232,19 @@ async function fetchSoulSource(prepared, dest) {
227
232
  if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
228
233
  }
229
234
 
235
+ /** The digests a soul copy may carry as its source was fetched (fetchSoulSource): the copy itself, and,
236
+ * when it holds the CLAUDE.md → AGENTS.md alias, the copy without it, since fetchSoulSource adds that
237
+ * alias to a source that has none. A package soul's copy is intact when one of them is its digest. */
238
+ export function soulCopyDigests(dir) {
239
+ const options = { allowSymlinks: SOUL_ALIAS_SYMLINK };
240
+ const alias = join(dir, "CLAUDE.md");
241
+ let aliased = false;
242
+ try { aliased = lstatSync(alias).isSymbolicLink() && readlinkSync(alias) === "AGENTS.md"; } catch { aliased = false; }
243
+ const digests = [contentDigest(dir, options)];
244
+ if (aliased) digests.push(contentDigest(dir, { ...options, omit: (rel) => rel === "CLAUDE.md" }));
245
+ return digests;
246
+ }
247
+
230
248
  /** A preview's soul source. A preview writes NOTHING in the deployment (K6b), so it
231
249
  * never populates the per-commit cache or moves the `soul` pointer: it reads the
232
250
  * cache entry for the prepared commit when one is already complete, else fetches
@@ -473,13 +491,13 @@ export async function soulSpawnability(local, discovery, lock, soulEntry, { remo
473
491
 
474
492
  /** The async half of a spawn: everything that touches the network. Returns a
475
493
  * PREPARED object that `spawnInstance` consumes synchronously. */
476
- export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
494
+ export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride, soulEntry: entryOverride } = {}) {
477
495
  const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
478
496
  const local = found.local;
479
497
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
480
498
  const lock = readLockIfPresent(deployment);
481
499
  const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
482
- const soulEntry = findSoulEntry(discovery, soulName);
500
+ const soulEntry = entryOverride ?? findSoulEntry(discovery, soulName);
483
501
  const disabled = disabledEntry(local, soulEntry);
484
502
  if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
485
503
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
@@ -488,6 +506,26 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
488
506
  return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn, launch };
489
507
  }
490
508
 
509
+ /** The first soul of the deployment, by name (then qualified name), not disabled here, whose
510
+ * resolution `provides(resolution)`: each soul is prepared in turn over one discovery, and the search
511
+ * stops at the first that does. A soul whose resolution is refused cannot provide anything and is
512
+ * skipped, named with its code. → { prepared | null, skipped: [{ soul, code }] } */
513
+ export async function prepareFirstProviding(contextDir, provides, { remoteOptions, remote } = {}) {
514
+ const found = loadLocal(contextDir);
515
+ const deployment = dirname(found.path);
516
+ const discovery = await discoverOrStandalone(found.local, { lock: readLockIfPresent(deployment), remoteOptions, remote });
517
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : qualifiedSoulName(a) < qualifiedSoulName(b) ? -1 : 1);
518
+ const entries = soulCandidates(discovery).map((c) => c.entry).filter((e) => disabledEntry(found.local, e) === null).sort(byName);
519
+ const skipped = [];
520
+ for (const soulEntry of entries) {
521
+ let prepared;
522
+ try { prepared = await prepareInstance(contextDir, null, { remoteOptions, remote, discovery, soulEntry }); }
523
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) { skipped.push({ soul: qualifiedSoulName(soulEntry), code: e.code }); continue; } throw e; }
524
+ if (provides(prepared.resolution)) return { prepared, skipped };
525
+ }
526
+ return { prepared: null, skipped };
527
+ }
528
+
491
529
  /** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
492
530
  * operator's access context cannot see this host" — the ONLY reasons that turn a
493
531
  * workspace spawn into the standalone view. `network` / `timeout` are transient: the
@@ -24,7 +24,7 @@ import { loadLocal } from "./workspace.mjs";
24
24
  import { packageRef, refForKey } from "./resolve.mjs";
25
25
  import { MODULES_DIR } from "./materialize.mjs";
26
26
  import * as defaultRemote from "./remote.mjs";
27
- import { prepareInstance } from "./instance-resolution.mjs";
27
+ import { prepareFirstProviding, prepareInstance, qualifiedSoulName } from "./instance-resolution.mjs";
28
28
 
29
29
  function err(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
30
30
 
@@ -105,32 +105,42 @@ export async function ensureModuleTree(deployment, module, lock, { catalog = nul
105
105
  *
106
106
  * contextDir — where the operator stands (oats-local.yaml is found walking up)
107
107
  * namespace — the `<ns>` the operator typed (`okf`)
108
- * soulName — the value of --soul; REQUIRED (undefined/true/"" → E_BAD_ARGS)
108
+ * soulName — the value of --soul (true or "" → E_BAD_ARGS). Absent: the first soul of the
109
+ * deployment, by name, not disabled here, whose resolution provides the namespace
110
+ * (feature operator-default-soul); none → E_BAD_ARGS
109
111
  *
110
112
  * → { deployment, soul: { name, repoKey, commit, team }, module, manifest, commands,
111
- * settings: <resolution.payloads[cap] or {}>, resolution, prepared,
112
- * ensureTree(): Promise<dir> } — or null when no module of the soul's
113
+ * settings: <resolution.payloads[cap] or {}>, resolution, prepared, defaultSoul,
114
+ * ensureTree(): Promise<dir> } — or null when no module of the named soul's
113
115
  * resolution claims the namespace (the caller answers E_UNKNOWN_COMMAND).
116
+ * `defaultSoul` is the qualified name of the soul chosen without --soul, else null.
114
117
  * Two modules claiming one namespace → E_DUPLICATE_NAMESPACE. Everything
115
118
  * prepareInstance can throw (E_SOUL_UNKNOWN,
116
119
  * E_REMOTE_UNREADABLE, …) propagates untouched.
117
120
  */
118
121
  export async function resolveOperatorDispatch(contextDir, namespace, soulName, { remoteOptions, remote, catalog = null, fetch, prepare = prepareInstance } = {}) {
119
122
  if (typeof namespace !== "string" || !namespace) throw err("E_BAD_ARGS", "a command namespace is required");
120
- if (typeof soulName !== "string" || !soulName.trim()) {
121
- throw err("E_BAD_ARGS", `oats ${namespace}: outside an instance home a capability command resolves as a spawn would — pass --soul <name> (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
123
+ const claimantsOf = (resolution) => (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
124
+ let prepared, defaultSoul = null;
125
+ if (soulName === undefined) {
126
+ const first = await prepareFirstProviding(resolvePath(contextDir), (resolution) => claimantsOf(resolution).length > 0, { remoteOptions, remote });
127
+ if (!first.prepared) throw err("E_BAD_ARGS", `no soul of this deployment provides the ${namespace} namespace; pass --soul <name>`, { namespace, flag: "--soul", ...(first.skipped.length ? { skipped: first.skipped } : {}) });
128
+ prepared = first.prepared;
129
+ defaultSoul = qualifiedSoulName(prepared.soulEntry);
130
+ } else {
131
+ if (typeof soulName !== "string" || !soulName.trim()) throw err("E_BAD_ARGS", `oats ${namespace}: --soul needs a soul name (the soul whose resolution provides the "${namespace}" namespace)`, { namespace, flag: "--soul" });
132
+ prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
122
133
  }
123
- const prepared = await prepare(resolvePath(contextDir), soulName, { remoteOptions, remote });
124
134
  const { resolution, lock } = prepared;
125
135
  const deployment = prepared.deployment ?? dirname(loadLocal(contextDir).path);
126
- const claimants = (resolution.modules || []).filter((m) => m?.manifest && m.manifest.command === namespace && m.manifest.commands && typeof m.manifest.commands === "object");
136
+ const claimants = claimantsOf(resolution);
127
137
  if (!claimants.length) return null;
128
138
  if (claimants.length > 1) throw err("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${namespace}": ${claimants.map((m) => m.name).join(", ")}`, { namespace, modules: claimants.map((m) => m.name) });
129
139
  const module = claimants[0];
130
140
  const payload = resolution.payloads?.[module.name];
131
141
  const settings = payload && typeof payload === "object" ? { ...payload } : {};
132
142
  return {
133
- deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared,
143
+ deployment, soul: resolution.soul, module, manifest: module.manifest, commands: module.manifest.commands, settings, resolution, prepared, defaultSoul,
134
144
  ensureTree: () => ensureModuleTree(deployment, module, lock, { catalog, remote, remoteOptions: remoteOptions ?? prepared.remoteOptions, fetch }),
135
145
  };
136
146
  }