@awebai/oats 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,10 +25,12 @@ A soul is durable and committed. It is the part you review, improve, and keep.
25
25
  AGENTS.md # canonical operating doc
26
26
  CLAUDE.md → AGENTS.md
27
27
  skills/ # skills specific to this expert
28
+ okf.json # if the soul uses oats.okf: { version: 1, owner, owns: ["<base>/<node>"], reads: […] } — the provider's, not the kernel's
28
29
  ```
29
30
 
30
- (A deployment's own `agents/<name>/soul/` has the same shape; a member soul is
31
- fetched there at its discovered commit before its first spawn.)
31
+ (A deployment's `agents/<name>/souls/<commit12>/` has the same shape; a member
32
+ soul is fetched there at its discovered commit before its first spawn, and
33
+ `agents/<name>/soul` points at the current commit.)
32
34
 
33
35
  ### `soul.yaml` v2
34
36
 
@@ -47,8 +49,8 @@ capabilities: # WHERE each capability comes from —
47
49
  acme-house-style: off # removes a workspace/team default
48
50
 
49
51
  knowledge: # provider payloads — opaque to the kernel, consumed by the slot's capability
50
- owns: release-manager
51
- reads: [platform-engineer]
52
+ harvest-runtime: claude # (oats.okf 2.1.3 reads only its binding's settings keys here; what the soul
53
+ # owns/reads is in this directory's okf.json — see "Soul anatomy")
52
54
  messaging:
53
55
  channels: [acme-eng]
54
56
  tasks: none # `none` empties the slot (drops the workspace default)
@@ -63,7 +65,7 @@ compatibility: # optional floors on PACKAGE versions
63
65
  | `team` | A label declared in the workspace's `teams:`; may add `defaults.byTeam` capabilities. Never gates or restricts. |
64
66
  | `private` | `true` keeps the soul out of workspace discovery; its own repo can still spawn it. |
65
67
  | `capabilities` | `<cap>: { from: here \| <repo key> \| package }` or `<cap>: off`. Composed over `defaults.<slot>` ⊕ `defaults.capabilities` ⊕ `defaults.byTeam[team]`; the soul wins. |
66
- | `knowledge` / `messaging` / `tasks` | The slot's provider payload (true of every instance of the soul), or `none`. Merged with `oats-local.yaml` `settings.<cap>` and `spawn --provider <cap>`; the provider's `binding` contract validates the result. |
68
+ | `knowledge` / `messaging` / `tasks` | The slot's provider payload (true of every instance of the soul), or `none`. Merged with `oats-local.yaml` `settings.<cap>` and `spawn --provider <cap>`; the provider's `binding` contract validates the result — and refuses keys it does not declare. For `oats.okf` 2.1.3 the admitted keys are its settings (`bindings-file`, `state-dir`, `harvest-runtime`, `harvest-model`); the soul's `owns`/`reads` live in `souls/<name>/okf.json`, which OKF reads from the soul directory. |
67
69
  | `compatibility` | `<cap>: <semver range>` checked against the locked package version (`E_COMPATIBILITY`). |
68
70
 
69
71
  Schema: [`soul.schema.json`](soul.schema.json). Not in v2: `kind`, `type`,
@@ -82,7 +84,9 @@ souls — is ordinary capability content: **`oats.core`** (package
82
84
  `defaults.capabilities: { oats.core: { from: package } }`; a soul may say
83
85
  `oats.core: off`. **`oats.setup`** (same package) carries the whole-architecture
84
86
  knowledge an onboarding expert needs. Neither is kernel magic; the kernel still
85
- composes its own instance-boundary and work-mode briefings.
87
+ composes its own instance-boundary and work-mode briefings — and, when
88
+ `oats.core` resolves as a module, leaves the "You run on OATS" briefing to the
89
+ module's inject (one block, not two; 0.25.2).
86
90
 
87
91
  ## Instance anatomy
88
92
 
@@ -134,7 +138,7 @@ skills and instructions), a workspace spawn records:
134
138
  }
135
139
  },
136
140
  "providers": {
137
- "oats.okf": { "owns": "release-manager", "reads": ["platform-engineer"], "state-dir": "/Users/ana/.oats/okf" },
141
+ "oats.okf": { "bindings-file": "/Users/ana/.oats/okf-bindings.json", "state-dir": "/Users/ana/.oats/okf", "harvest-runtime": "claude" },
138
142
  "acme-release-tooling": {}
139
143
  },
140
144
  "workspace": {
@@ -149,9 +153,13 @@ skills and instructions), a workspace spawn records:
149
153
  shows `moved` / `missing` per module (drift is shown, not prevented).
150
154
  - `providers.<cap>` — the merged provider payload the capability was bound with
151
155
  (soul ⊕ machine settings ⊕ `--provider`), so a later inspection can tell
152
- which instance holds a retained seat or a one-off state root.
156
+ which instance holds a retained seat or a one-off state root. `spawn
157
+ --preview` shows the same map before anything exists, as `settings.<cap>`,
158
+ beside `providers` (the `--provider` flags as given).
153
159
  - `workspace` — the workspace commit observed at spawn, the soul's repo/commit/
154
- team, and the **resolution revision** the spawn decision bound.
160
+ team, and the **resolution revision** the spawn decision bound. `oats status`
161
+ compares `workspace.soul` with the member's current commit too: `soul: <name>
162
+ from <member> @ <c7> [member moved since …]` (`--json`: `instances[].soul`).
155
163
 
156
164
  A running instance never changes under itself: a member moving or a package
157
165
  bump affects only new spawns.
@@ -176,14 +184,16 @@ instructions and pins model/provider settings; it excludes nothing.
176
184
  ```bash
177
185
  oats spawn release-manager --purpose cut-3.2 --task "…" # a soul of a confirmed member
178
186
  oats spawn release-manager --preview --json # decide everything, create nothing
179
- oats spawn release-manager --provider oats.aweb identity.source=retained:release-seat # instance-level payload
187
+ oats spawn release-manager --provider oats.aweb identity.source=/abs/path/to/retained/.aw # instance-level payload
180
188
  ```
181
189
 
182
190
  From a deployment (where `oats-local.yaml` is), a spawn: reads the local file →
183
191
  discovers the workspace over its remotes and confirms membership → finds the
184
192
  soul among the confirmed members (or `external:`; an ambiguous bare name is
185
193
  `E_SOUL_AMBIGUOUS` — say `<repo>/<soul>`) → fetches the soul's source into
186
- `<agents-root>/<soul>/soul/` at its commit → resolves every capability by
194
+ `<agents-root>/<soul>/souls/<commit12>/` at its commit (the home links that
195
+ directory; `<agents-root>/<soul>/soul` points at the current one) → resolves
196
+ every capability by
187
197
  `from:` (member = latest, package = locked + approved) → creates the home →
188
198
  **materializes each module whole** into `.oats/modules/` and copies its skills
189
199
  into `.agents/skills/` (a transaction: any failure leaves nothing behind) →
@@ -194,8 +204,10 @@ unchanged. This is a normal agent process with its own home and tools, not a
194
204
  subagent call.
195
205
 
196
206
  `--preview` reports `modules[]` (`from`, `layer`, `changedSince` the newest
197
- previous instance of the soul), `team`, the `resolution` revision and the
198
- decision it would bind; the apply refuses with `E_DECISION_STALE` if a member
207
+ previous instance of the soul), `team`, the `resolution` revision, the decision
208
+ it would bind, `providers` (the `--provider` map exactly as given) and
209
+ `settings.<cap>` (the merged payload each provider's binding will receive);
210
+ the apply refuses with `E_DECISION_STALE` if a member
199
211
  moved in between. `--provider <cap> key=value` (repeatable; dotted keys nest)
200
212
  must name a capability the soul resolves (`E_CAPABILITY_MISSING` otherwise) and
201
213
  needs a workspace deployment. The full DTOs are in
@@ -208,7 +220,7 @@ Examples of spawn hooks:
208
220
  registers a durable source plus its per-source schedule definition. Missing
209
221
  knowledge is an error, not permission to bootstrap an empty substitute.
210
222
  - `oats.aweb` mints a messaging identity — or, with
211
- `--provider oats.aweb identity.source=retained:<seat>`, re-takes a retained
223
+ `--provider oats.aweb identity.source=/abs/path/of/the/.aw/to/retain`, re-takes a retained
212
224
  one for exactly this instance.
213
225
 
214
226
  ### Work
@@ -324,7 +336,10 @@ directory is for, not a place to settle in.
324
336
  ### `worktree` — isolated branch
325
337
 
326
338
  `work/` is a git worktree on the instance's own branch, by default
327
- `agents/<instance>`.
339
+ `agents/<instance>`. The worktree is created from the member's **clone**, found
340
+ as `--repo`, then `oats-local.yaml` `clones:`, then `<deployment>/<member name>`
341
+ (`E_CLONE_MISSING` / `E_CLONE_MISMATCH` otherwise — see
342
+ [configuration.md](configuration.md)).
328
343
 
329
344
  Use this for agents that will edit code or docs independently.
330
345
 
@@ -341,7 +356,8 @@ inside each fresh worktree. Failures warn but do not block spawn.
341
356
 
342
357
  ### `checkout` — shared current branch
343
358
 
344
- `work/` is a symlink to the repo checkout itself.
359
+ `work/` is a symlink to the repo checkout itself (the member clone, found as for
360
+ `worktree`).
345
361
 
346
362
  Use this for maintainers, coordinators, auditors, or agents working on the
347
363
  repo's current state.
@@ -382,8 +398,10 @@ exchanged for a symlink. Recovery does not replace the worker's delivery protoco
382
398
 
383
399
  ### `workspace` — cross-repo coordinator
384
400
 
385
- `work/` is a symlink to the **whole deployment** (the `<name>-workspace/`
386
- directory holding `oats-local.yaml` and the member clones) — not a repo. Every
401
+ `work/` is a symlink to the **whole deployment** — the directory holding
402
+ `oats-local.yaml`, with `agents/` and the member clones that sit beside it —
403
+ not a repo (0.25.1; a member cloned elsewhere is reached through
404
+ `oats-local.yaml` `clones:`). Every
387
405
  member repo is read-context; the instance's product is coordination:
388
406
  routing, analysis, task-writing, messaging, spawning specialists.
389
407
 
@@ -122,9 +122,8 @@ capabilities:
122
122
  acme-deploy: { from: package } # provided by acme.tools, pinned in packages:
123
123
  acme-house-style: off # removes a workspace default
124
124
 
125
- knowledge: # provider payload, opaque to the kernel
126
- owns: release-manager
127
- reads: [platform-engineer]
125
+ knowledge: # provider payload, opaque to the kernel — the slot capability's BINDING keys
126
+ harvest-runtime: claude # (oats.okf 2.1.3: what this soul owns/reads is in souls/<name>/okf.json, not here — see below)
128
127
  messaging:
129
128
  channels: [acme-eng]
130
129
  tasks: none # empties the slot
@@ -133,7 +132,15 @@ compatibility: # optional FLOORS on package versions
133
132
  oats.okf: ">=2.1"
134
133
  ```
135
134
 
136
- Beside it: `AGENTS.md` (canonical), `CLAUDE.md → AGENTS.md`, `skills/`. Every
135
+ Beside it: `AGENTS.md` (canonical), `CLAUDE.md → AGENTS.md`, `skills/`, and
136
+ whatever the slot providers read from the soul directory — for `oats.okf`
137
+ 2.1.3 that is **`okf.json`** (`{ version: 1, owner, owns: ["<base>/<node>"],
138
+ reads: […] }`, written by `oats okf init|migrate`), the soul's knowledge
139
+ declaration; it travels with the soul into the per-commit soul cache. The
140
+ `knowledge:` payload on `soul.yaml` reaches OKF as `OATS_SETTINGS` and may
141
+ carry only the binding's settings keys (`bindings-file`, `state-dir`,
142
+ `harvest-runtime`, `harvest-model`); `owns`/`reads`/`root` there are refused by
143
+ the provider, not read (a soul payload grammar is an OKF follow-up). Every
137
144
  `souls/*/soul.yaml` in a member is discoverable; one that wants to stay
138
145
  internal says `private: true` (spawnable only from its own repo). A soul's
139
146
  `name` must equal its directory name; the first of two souls declaring one
@@ -257,8 +264,11 @@ integrity on the next `oats sync` and asks again.
257
264
 
258
265
  `oats sync` confirms membership, resolves every `packages:` entry to a commit +
259
266
  content digest, asks (on a terminal) for any missing per-version executable
260
- approval, writes `oats-lock.json` (lockfileVersion 3) and reports what changed.
261
- `oats package add <id> <version|git:…@…>` / `oats package remove <id>` edit
267
+ approval — or takes it from repeatable `--approve <id>@<version>` flags for
268
+ unattended runs (each approves exactly the entry the resolution contains; the
269
+ digest is always computed, never typed; Ctrl+D at the prompt is a decline,
270
+ exit `2`) — writes `oats-lock.json` (lockfileVersion 3), creates `agents/` if
271
+ absent and reports what changed. `oats package add <id> <version|git:…@…>` / `oats package remove <id>` edit
262
272
  `packages:` in the workspace file when it is tracked by the current checkout,
263
273
  else print the line to add — the workspace file is shared through Git. Details:
264
274
  [packages.md](packages.md).
@@ -298,6 +308,7 @@ Nothing is symlinked, nothing is shared between instances.
298
308
  ```
299
309
  <agents-root>/<soul>/instances/<instance>/
300
310
  ├── AGENTS.md # composed: soul AGENTS.md + kernel/work-mode blocks + each module's inject
311
+ │ # (with oats.core resolved, the module's "You run on OATS" block is the only one — the kernel's legacy copy is suppressed)
301
312
  ├── CLAUDE.md → AGENTS.md
302
313
  ├── .agents/skills/<capability>/<skill>/SKILL.md # full copies; where pi/codex look
303
314
  ├── .claude/skills → ../.agents/skills
@@ -317,10 +328,14 @@ bumped affects only new spawns. Details and DTOs:
317
328
  [souls-and-instances.md](souls-and-instances.md), [desktop-cli-api.md](desktop-cli-api.md).
318
329
 
319
330
  **Drift is shown, not prevented.** `oats status` compares each instance's
320
- recorded modules with the workspace's current picture: `current`, `moved`
321
- (member or package now at another commit) or `missing` (capability no longer
322
- present, member unconfirmed, package no longer locked). `oats spawn --preview`
323
- lists `changedSince` the newest previous instance of the same soul.
331
+ recorded modules — and its recorded **soul source** (`instance.json.workspace.soul`)
332
+ — with the workspace's current picture: `current`, `moved` (member or package
333
+ now at another commit) or `missing` (capability no longer present, member
334
+ unconfirmed, package no longer locked). The text form is `soul: <name> from
335
+ <member> @ <c7> [member moved since …]` above the module rows; `--json`
336
+ carries `instances[].soul`. `oats spawn --preview` lists `changedSince` the
337
+ newest previous instance of the same soul, plus `providers` (the `--provider`
338
+ map as given) and `settings.<cap>` (the merged payload each provider receives).
324
339
 
325
340
  **Harnesses start normally.** OATS is a skill contributor, not a skill sandbox:
326
341
  cwd = the instance home, the harness's own skill discovery intact
@@ -344,9 +359,9 @@ store** — it organises and can supply defaults. The messaging provider's paylo
344
359
 
345
360
  | What it is | Where | Example |
346
361
  |---|---|---|
347
- | True of every instance of the soul | `soul.yaml` → `knowledge:` / `messaging:` / `tasks:` | `knowledge: { owns: release-manager }` |
362
+ | True of every instance of the soul | `soul.yaml` → `knowledge:` / `messaging:` / `tasks:` | `messaging: { channels: [acme-eng] }`; `knowledge: { harvest-runtime: claude }` |
348
363
  | A fact about this machine | `oats-local.yaml` → `settings.<cap>.<key>` (absolute paths are refused in the workspace file) | `settings.oats.okf.state-dir: /Users/ana/.oats/okf` |
349
- | A fact about **this spawn** | `oats spawn … --provider <cap> key=value` (repeatable; dotted keys nest) → `instance.json.providers.<cap>` | `--provider oats.aweb identity.source=retained:release-seat` |
364
+ | A fact about **this spawn** | `oats spawn … --provider <cap> key=value` (repeatable; dotted keys nest) → `instance.json.providers.<cap>` | `--provider oats.aweb identity.source=/abs/path/to/retained/.aw` |
350
365
 
351
366
  The merged payload is `workspace.messaging` (messaging slot only; its base
352
367
  keys ⊕ `byTeam[<soul's team>]`, with `byTeam` itself stripped) ⊕ soul slot
@@ -365,14 +380,26 @@ messaging:
365
380
 
366
381
  A soul with `team: cloud` hands its messaging provider `{ team: aweb:example.cloud, … }`;
367
382
  a label under `byTeam` that is not declared in `teams:` is `E_WORKSPACE_SCHEMA`.
368
- A store (`stores: { <name>: <repo ref> }`) names a repository; where the base
369
- lives inside it is the knowledge provider's own binding key (`root` for OKF),
370
- given in the payload — a repo ref never carries a `#path`.
383
+ **`byTeam` is kernel-merged; whether a provider honours what arrives is the
384
+ provider's.** `spawn --preview` shows the merged `settings.<cap>` so the
385
+ delivery is verifiable, and `instance.json.providers.<cap>` records it — but
386
+ oats.aweb **1.11.2 does not read `team` from its payload** (it resolves the
387
+ team from the removed `oats-config.yaml` `team:` block, else the active team at
388
+ the `.aw` root it finds), so for 1.11.2 `byTeam` is a recorded intent, not a
389
+ per-label identity; the per-repo `.aw` placement in
390
+ [rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-oatsaweb-1112-and-what-byteam-does-today)
391
+ is the working alternative. An oats.aweb release that reads `team` from the
392
+ payload closes the gap without a workspace edit (release notes will name it).
393
+ A store (`stores: { <name>: <repo ref> }`) names a repository; where a
394
+ knowledge base lives inside it is the knowledge provider's own concern — for
395
+ OKF 2.1.3 that is the **bindings file** (`bases.<alias>.repository` + `root`,
396
+ `oats-local.yaml settings.oats.okf.bindings-file`), not a soul payload key; a
397
+ repo ref never carries a `#path`.
371
398
 
372
399
  `--provider` for a capability the soul does not resolve is `E_CAPABILITY_MISSING`;
373
400
  `__proto__`/`constructor`/`prototype` as a key at any depth is refused.
374
401
 
375
- ## Discovery over remotes and the `<name>-workspace/` convention
402
+ ## Discovery over remotes and the deployment directory
376
403
 
377
404
  Discovery and resolution work against **Git remotes, never local clones**. The
378
405
  kernel fetches `oats-workspace.yaml`, each member's `oats-membership.yaml`,
@@ -382,25 +409,35 @@ BatchMode: nothing ever prompts). Neither the repo that defines a capability nor
382
409
  the repo that hosts the workspace needs to be cloned.
383
410
 
384
411
  **The only thing that needs a clone is a soul's work target** (`work:
385
- worktree | checkout`). Spawning a soul whose repo is not yet cloned is a guided
386
- clone-then-spawn, a job for the onboarding skill, not the kernel.
387
-
388
- The taught default is one folder named after the workspace:
412
+ worktree | checkout`). The kernel finds it, first hit wins: (1) `oats spawn
413
+ … --repo <abs path>`; (2) `oats-local.yaml` `clones: { <repo key>: <abs
414
+ path> }` (keys are canonical repo keys — any ref spelling is normalised through
415
+ `parseRepoRef`); (3) the convention `<deployment>/<member name>` (the last
416
+ segment of the repo key; a member named `agents` is looked for at
417
+ `<deployment>/agents-repo`, since `agents/` is the instance root). None →
418
+ `E_CLONE_MISSING` naming the three remedies; a directory whose `origin` is a
419
+ different repo → `E_CLONE_MISMATCH`. Spawning a soul whose repo is not yet
420
+ cloned is a guided clone-then-spawn, a job for the onboarding skill, not the
421
+ kernel.
422
+
423
+ The deployment directory is **yours to choose** (decision 9) — an existing folder that already holds your member clones is the usual case; `oats onboard <dir>` adds what the kernel needs and nothing else:
389
424
 
390
425
  ```
391
- ~/acme-workspace/ ← "<name>-workspace"
426
+ ~/acme/ ← the directory you chose
392
427
  ├── oats-local.yaml ← which workspace this machine realizes + host paths + disabled souls
393
428
  ├── oats-lock.json ← exact commit + integrity + per-version approval per package
394
429
  ├── agents/ ← instance homes (each self-contained) + fetched soul sources
395
- ├── platform/ ← clone of github.com/acme/platform (only if someone works IN it)
430
+ ├── platform/ ← clone of github.com/acme/platform (only if someone works IN it; may live elsewhere — see clones:)
396
431
  └── tools/
397
432
  ```
398
433
 
399
434
  The kernel never depends on this shape: `oats-local.yaml` is found by walking
400
- up from the current directory (`E_LOCAL_MISSING` otherwise); clones are found
401
- through `clones:` or the convention. A soul that lives in a member repo is
402
- fetched into `<agents-root>/<soul>/soul/` at its discovered commit before its
403
- first spawn (idempotent per commit); its instances then materialize as above.
435
+ up from the current directory (`E_LOCAL_MISSING` otherwise); `agents/` is
436
+ created by `oats sync` when absent; clones are found through `--repo`,
437
+ `clones:` or the convention, in that order. A soul that lives in a member repo
438
+ is fetched into `<agents-root>/<soul>/souls/<commit12>/` at its discovered
439
+ commit before its first spawn (idempotent per commit; `<agents-root>/<soul>/soul`
440
+ points at the current one); its instances then materialize as above.
404
441
 
405
442
  ## The standalone case
406
443
 
@@ -419,7 +456,16 @@ approved like any package (a soul may say `oats.core: off`); and the
419
456
  operator's `oats-local.yaml` may name the repo directly (`workspace: <member
420
457
  ref>` — the kernel notices it is a member whose workspace it cannot read and
421
458
  falls back to the standalone view — or `standalone: <repo ref>` to ask for
422
- that view explicitly).
459
+ that view explicitly). `oats onboard` lists the host among the clones to make
460
+ like any member (the host is a member; its souls may need a work clone); under
461
+ an explicit `standalone:` header its next steps say so and name that one repo.
462
+
463
+ **Executables from public members.** Membership is the trust (decision 2): a
464
+ member capability's hooks and command scripts run on every operator's machine at
465
+ spawn, gated by nothing but the handshake. In a mixed public/private
466
+ organisation keep **souls only** in public members and let executable
467
+ capabilities come from packages (approved per version in the lock) or from
468
+ private members.
423
469
 
424
470
  **Hosting the workspace file when some members are private.** Everyone who
425
471
  can read the workspace file sees the member list. So: a public member never
package/lib/core.mjs CHANGED
@@ -594,6 +594,15 @@ export function ensureRoot(cwd) {
594
594
  const root = findRoot(cwd);
595
595
  if (!root) {
596
596
  const from = resolve(cwd ?? process.cwd());
597
+ // Workspace model: an oats-local.yaml above `from` IS a deployment whose instance
598
+ // root (<deployment>/agents/) was never created — name that remedy, not a v1 verb.
599
+ let localDeployment = null;
600
+ for (let d = from; ; d = dirname(d)) { if (existsSync(join(d, "oats-local.yaml"))) { localDeployment = d; break; } if (dirname(d) === d) break; }
601
+ if (localDeployment) {
602
+ throw oatsError("E_NO_DEPLOYMENT",
603
+ `no instance root walking up from ${from}: ${join(localDeployment, "oats-local.yaml")} names this deployment but ${join(localDeployment, "agents")} does not exist — mkdir ${join(localDeployment, "agents")} (or run \`oats sync --dir ${localDeployment}\`, which creates it)`,
604
+ { from, looked: ["agents/", "local-agents/"], deployment: localDeployment, local: join(localDeployment, "oats-local.yaml"), remedy: `mkdir ${join(localDeployment, "agents")} (or run oats sync)` });
605
+ }
597
606
  throw oatsError("E_NO_DEPLOYMENT",
598
607
  `no deployment found walking up from ${from}: no agents/ or local-agents/ directory — create one (mkdir agents, or \`oats create <name> --local\`), run from the deployment (where oats-local.yaml lives), or set PI_AGENTS_ROOT`,
599
608
  { from, looked: ["agents/", "local-agents/"] });
@@ -4287,10 +4296,22 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
4287
4296
  resolved.layers = Object.fromEntries(Object.entries(prepared.resolution.slots || {}).map(([slot, mod]) => [slot, mod ? { capability: mod } : null]));
4288
4297
  }
4289
4298
  const wanted = [];
4290
- const declaredOperations = declaredOperationalCapabilities(soulDir);
4299
+ // Kernel "You run on OATS" block (R3, 0.25.2). Workspace model: the RESOLUTION
4300
+ // decides — when it carries oats.core / oats.setup as a module, that module's
4301
+ // inject and skills are the operational essentials and the kernel's legacy
4302
+ // block (which names the bundled `oats` skill no such home has) is suppressed,
4303
+ // whatever the soul's v1 `requires:` block says. A soul that resolves NO such
4304
+ // module (`oats.core: off`) still gets the kernel block: the instance needs the
4305
+ // essentials from somewhere. The classic path keeps reading `requires`.
4306
+ const resolvedOperations = prepared
4307
+ ? ["oats.core", "oats.setup"].filter((id) => (prepared.resolution?.modules || []).some((m) => m.name === id))
4308
+ : null;
4309
+ const declaredOperations = prepared ? resolvedOperations : declaredOperationalCapabilities(soulDir);
4291
4310
  const oatsCoreDeclared = declaredOperations.includes("oats.core");
4292
4311
  if (declaredOperations.length) resolved.kernelInjection = { inject: undefined,
4293
- provenance: `declared ${declaredOperations.join(", ")} ${declaredOperations.length === 1 ? "capability" : "capabilities"}` };
4312
+ provenance: prepared
4313
+ ? `declared ${declaredOperations.join(", ")} (workspace module)`
4314
+ : `declared ${declaredOperations.join(", ")} ${declaredOperations.length === 1 ? "capability" : "capabilities"}` };
4294
4315
  const kernelInject = resolved.kernelInjection?.inject;
4295
4316
  if (kernelInject && existsSync(kernelInject)) wanted.push(["kernel:oats", kernelInject]);
4296
4317
  if (kind === "local") {
@@ -6131,7 +6152,13 @@ function* spawnBody(root, agent, o = {}) {
6131
6152
  if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
6132
6153
  if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
6133
6154
  const launch = o.launch !== false;
6134
- const repoAbs = resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
6155
+ // Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
6156
+ // boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
6157
+ // is a plain directory with member clones beside it, not a Git checkout. It is
6158
+ // the execution/config context too; no Git identity is required or recorded.
6159
+ const preparedDeployment = o.prepared && work === "workspace" && typeof o.prepared.deployment === "string" && o.prepared.deployment ? resolve(o.prepared.deployment) : undefined;
6160
+ if (preparedDeployment !== undefined && !(existsSync(preparedDeployment) && statSync(preparedDeployment).isDirectory())) throw oatsError("E_BAD_ARGS", `workspace mode: the deployment directory ${preparedDeployment} (where oats-local.yaml lives) is not a directory`);
6161
+ const repoAbs = preparedDeployment ?? resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
6135
6162
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
6136
6163
  // Launch selection: a named configuration (explicit, or the soul's
6137
6164
  // launch-config default), or none; the runtime and model follow from it.
@@ -6563,8 +6590,16 @@ function* spawnBody(root, agent, o = {}) {
6563
6590
  // directory basename with a `module:<cap>` source — not the classic chain's view.
6564
6591
  const preparedCapabilities = o.prepared ? o.prepared.resolution.modules.map((m) => ({ name: m.name, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}` })) : null;
6565
6592
  const preparedSkills = o.prepared ? o.prepared.resolution.modules.flatMap((m) => (m.manifest?.skills || []).filter((s) => typeof s === "string" && s).map((s) => ({ name: basename(s.replace(/\/+$/, "")), source: `module:${m.name}` }))) : [];
6593
+ // R5 (0.25.2, decision 14): the preview shows the provider payloads the apply
6594
+ // will record — `providers` is the --provider map exactly as parsed (what the
6595
+ // operator typed; prepareInstance keeps it on prepared.spawn.providers) and
6596
+ // `settings.<cap>` is the MERGED payload per module (soul ⊕ workspace byTeam ⊕
6597
+ // local.settings ⊕ --provider), i.e. what the provider receives. Reserved and
6598
+ // poison keys were refused by resolveSoul before this point (E_WORKSPACE_SCHEMA).
6599
+ const preparedProviders = o.prepared ? structuredClone(o.prepared.spawn?.providers && typeof o.prepared.spawn.providers === "object" ? o.prepared.spawn.providers : {}) : null;
6600
+ const preparedSettings = o.prepared ? Object.fromEntries(o.prepared.resolution.modules.map((m) => [m.name, structuredClone(o.prepared.resolution.payloads?.[m.name] && typeof o.prepared.resolution.payloads[m.name] === "object" ? o.prepared.resolution.payloads[m.name] : {})])) : null;
6566
6601
  return deliver({
6567
- ...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
6602
+ ...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, declRevision: o.prepared.resolution.declRevision ?? null, payloadRevision: o.prepared.resolution.payloadRevision ?? null, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true, providers: preparedProviders, settings: preparedSettings } : {}),
6568
6603
  spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
6569
6604
  subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
6570
6605
  decision, preflight,
@@ -6684,7 +6719,29 @@ function* spawnBody(root, agent, o = {}) {
6684
6719
  initializeNativeHistory(home);
6685
6720
 
6686
6721
  // Body: the soul is linked for reference, while instructions are a generated instance-local view.
6687
- symlinkSync(soulDir, join(home, "soul"));
6722
+ // The home's soul link. A workspace soul (o.prepared) lives in the per-commit cache
6723
+ // agents/<name>/souls/<commit12>/ and agents/<name>/soul is only the kernel-owned
6724
+ // "current" POINTER (a symlink swapped by every ensureWorkspaceSoul, including a
6725
+ // preview's). The home links ITS commit's directory by realpath — never the pointer —
6726
+ // so a later fetch can move "current" without changing anything under a running
6727
+ // instance (decision 7); the OKF hook's owner pin (realpath of <home>/soul) stays valid.
6728
+ let homeSoulTarget = soulDir;
6729
+ if (o.prepared?.soulEntry?.commit) {
6730
+ const perCommit = join(agent._dir, "souls", String(o.prepared.soulEntry.commit).slice(0, 12));
6731
+ if (existsSync(join(perCommit, "soul.yaml"))) homeSoulTarget = realpathSync(perCommit);
6732
+ }
6733
+ if (homeSoulTarget === soulDir) {
6734
+ // Not prepared (or the cache entry is absent): still never link a swappable pointer —
6735
+ // when agents/<name>/soul is the kernel's pointer into souls/, link what it shows now.
6736
+ try {
6737
+ if (lstatSync(soulDir).isSymbolicLink()) {
6738
+ const real = realpathSync(soulDir), soulsReal = realpathSync(join(dirname(soulDir), "souls"));
6739
+ const rel = relative(soulsReal, real);
6740
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel) && !rel.includes(sep)) homeSoulTarget = real;
6741
+ }
6742
+ } catch { /* absent souls/ or unreadable link: link the classic soul dir */ }
6743
+ }
6744
+ symlinkSync(homeSoulTarget, join(home, "soul"));
6688
6745
  if (!o.prepared) { writeFileSync(join(home, "AGENTS.md"), composition.text); symlinkSync("AGENTS.md", join(home, "CLAUDE.md")); }
6689
6746
  else if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
6690
6747
 
@@ -6839,13 +6896,22 @@ function* spawnBody(root, agent, o = {}) {
6839
6896
  symlinkSync(resolve(o.workDir), join(home, "work"));
6840
6897
  branch = shTry(`git -C ${shq(o.workDir)} rev-parse --abbrev-ref HEAD`);
6841
6898
  } else if (work === "workspace") {
6842
- // Cross-repo coordinator: ./work is the TEAM SCOPE (deployment boundary), not
6843
- // a repo — member repos are read-context; repo edits are routed, not made.
6844
- // Requires a declared boundary: config team: scope, else the workspace scope.
6899
+ // Cross-repo coordinator: ./work is the deployment boundary, not a repo — member
6900
+ // repos are read-context; repo edits are routed, not made.
6901
+ // workspace model (o.prepared): the directory holding oats-local.yaml
6902
+ // (prepared.deployment — the directory holding oats-local.yaml, whatever it is named);
6903
+ // classic: config team: scope, else the workspace-scope oats-config.yaml.
6845
6904
  const resolvedCfgEarly = composition.resolved;
6846
- const wsRoot = resolvedCfgEarly.team?.scope
6905
+ const wsRoot = preparedDeployment
6906
+ || resolvedCfgEarly.team?.scope
6847
6907
  || resolvedCfgEarly.chain?.find((c) => c._level !== homedir())?._level;
6848
- if (!wsRoot) { rmSync(home, { recursive: true, force: true }); throw new Error(`workspace mode needs a declared boundary — add a "team:" block (or a workspace-scope oats-config.yaml) so ./work has a root`); }
6908
+ if (!wsRoot) {
6909
+ rmSync(home, { recursive: true, force: true });
6910
+ const remedy = o.prepared
6911
+ ? `add oats-local.yaml (workspace: <ref> or standalone: <ref>) to the deployment directory so ./work has a root`
6912
+ : `add a "team:" block (or a workspace-scope oats-config.yaml) so ./work has a root`;
6913
+ throw new Error(`workspace mode needs a declared boundary — ${remedy}`);
6914
+ }
6849
6915
  symlinkSync(resolve(wsRoot), join(home, "work"));
6850
6916
  branch = undefined; // no repo identity: the workspace is not a git tree
6851
6917
  } else {