@awebai/oats 0.25.1 → 0.25.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +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 +128 -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/release-notes/v0.25.3.md +19 -0
- package/docs/souls-and-instances.md +34 -16
- package/docs/workspaces.md +65 -26
- package/lib/core.mjs +71 -11
- package/lib/instance-resolution.mjs +132 -2
- package/lib/materialize.mjs +29 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
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": { "
|
|
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.
|
|
@@ -183,7 +191,9 @@ 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>/
|
|
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
|
|
198
|
-
|
|
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
|
|
@@ -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**
|
|
386
|
-
|
|
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
|
|
package/docs/workspaces.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
261
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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,7 +359,7 @@ 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:` | `
|
|
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
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
|
|
|
@@ -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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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`).
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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,9 @@ 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.
|
|
423
462
|
|
|
424
463
|
**Executables from public members.** Membership is the trust (decision 2): a
|
|
425
464
|
member capability's hooks and command scripts run on every operator's machine at
|
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/"] });
|
|
@@ -631,7 +640,7 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
631
640
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
632
641
|
/** Environment the kernel sets for every launch (identity, home, roots) and
|
|
633
642
|
* its reference aliases: a configuration may not name them. */
|
|
634
|
-
export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
|
|
643
|
+
export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
|
|
635
644
|
export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
|
|
636
645
|
const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
|
|
637
646
|
export function validateLaunchConfig(name, entry, where) {
|
|
@@ -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
|
-
|
|
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:
|
|
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") {
|
|
@@ -4785,7 +4806,31 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
|
|
|
4785
4806
|
return accepted;
|
|
4786
4807
|
}
|
|
4787
4808
|
|
|
4788
|
-
|
|
4809
|
+
/**
|
|
4810
|
+
* A soul's STABLE identity for capability hooks (OATS_SOUL_ID): what a provider
|
|
4811
|
+
* may key durable state on. Workspace soul: `<repo key>#<soul name>` — it does not
|
|
4812
|
+
* change when the member commits (the per-commit soul directory does) or when the
|
|
4813
|
+
* copy moves. Classic soul: the realpath of agents/<name>/soul, i.e. today's value,
|
|
4814
|
+
* so 0.24 deployments are unchanged. Order: an explicit `soulId` (a prepared spawn),
|
|
4815
|
+
* the home's recorded `instance.json.workspace.soul` (retire/launch of a workspace
|
|
4816
|
+
* home), else the path.
|
|
4817
|
+
*/
|
|
4818
|
+
export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
|
|
4819
|
+
if (typeof soulId === "string" && soulId) return soulId;
|
|
4820
|
+
if (home) {
|
|
4821
|
+
try {
|
|
4822
|
+
const meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
|
|
4823
|
+
const s = meta?.workspace?.soul;
|
|
4824
|
+
if (typeof s?.id === "string" && s.id) return s.id;
|
|
4825
|
+
if (typeof s?.repoKey === "string" && s.repoKey) return `${s.repoKey}#${meta.agent ?? agentName ?? ""}`;
|
|
4826
|
+
} catch { /* not a workspace home */ }
|
|
4827
|
+
}
|
|
4828
|
+
if (soulDir) { try { return realpathSync(soulDir); } catch { return soulDir; } }
|
|
4829
|
+
return "";
|
|
4830
|
+
}
|
|
4831
|
+
export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
|
|
4832
|
+
|
|
4833
|
+
export function runLifecycleHooks(event, { home, instance, agentName, soulDir, soulId, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
|
|
4789
4834
|
const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
|
|
4790
4835
|
const envOwners = new Map();
|
|
4791
4836
|
const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
|
|
@@ -4813,7 +4858,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
|
|
|
4813
4858
|
// the package STORE root — do not conflate them.
|
|
4814
4859
|
OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
|
|
4815
4860
|
OATS_CAPABILITY: cap.id, OATS_LAYER: cap.layer || "", OATS_ROOT: rootDir || "",
|
|
4816
|
-
OATS_SOUL: soulDir || "", OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
|
|
4861
|
+
OATS_SOUL: soulDir || "", OATS_SOUL_ID: stableSoulId({ soulId, home, soulDir, agentName }), OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
|
|
4817
4862
|
OATS_TEAM_NAME: resolved.team?.name || "", OATS_TEAM_ID: resolved.team?.id || "", OATS_TEAM_SCOPE: resolved.team?.scope || "",
|
|
4818
4863
|
...extraEnv,
|
|
4819
4864
|
// Hooks also run through direct core callers (not only bin/oats).
|
|
@@ -6569,8 +6614,16 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6569
6614
|
// directory basename with a `module:<cap>` source — not the classic chain's view.
|
|
6570
6615
|
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;
|
|
6571
6616
|
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}` }))) : [];
|
|
6617
|
+
// R5 (0.25.2, decision 14): the preview shows the provider payloads the apply
|
|
6618
|
+
// will record — `providers` is the --provider map exactly as parsed (what the
|
|
6619
|
+
// operator typed; prepareInstance keeps it on prepared.spawn.providers) and
|
|
6620
|
+
// `settings.<cap>` is the MERGED payload per module (soul ⊕ workspace byTeam ⊕
|
|
6621
|
+
// local.settings ⊕ --provider), i.e. what the provider receives. Reserved and
|
|
6622
|
+
// poison keys were refused by resolveSoul before this point (E_WORKSPACE_SCHEMA).
|
|
6623
|
+
const preparedProviders = o.prepared ? structuredClone(o.prepared.spawn?.providers && typeof o.prepared.spawn.providers === "object" ? o.prepared.spawn.providers : {}) : null;
|
|
6624
|
+
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;
|
|
6572
6625
|
return deliver({
|
|
6573
|
-
...(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 } : {}),
|
|
6626
|
+
...(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 } : {}),
|
|
6574
6627
|
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6575
6628
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6576
6629
|
decision, preflight,
|
|
@@ -6870,7 +6923,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6870
6923
|
// Cross-repo coordinator: ./work is the deployment boundary, not a repo — member
|
|
6871
6924
|
// repos are read-context; repo edits are routed, not made.
|
|
6872
6925
|
// workspace model (o.prepared): the directory holding oats-local.yaml
|
|
6873
|
-
// (prepared.deployment — the
|
|
6926
|
+
// (prepared.deployment — the directory holding oats-local.yaml, whatever it is named);
|
|
6874
6927
|
// classic: config team: scope, else the workspace-scope oats-config.yaml.
|
|
6875
6928
|
const resolvedCfgEarly = composition.resolved;
|
|
6876
6929
|
const wsRoot = preparedDeployment
|
|
@@ -6909,8 +6962,12 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
6909
6962
|
// Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
|
|
6910
6963
|
// memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
|
|
6911
6964
|
// messaging integration mints the comms identity. Kernel stays memory-agnostic.
|
|
6965
|
+
const preparedSoulId = o.prepared ? workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name) : undefined;
|
|
6966
|
+
// Hooks read the soul the HOME links (the per-commit directory for a workspace
|
|
6967
|
+
// soul), never the swappable agents/<name>/soul pointer: a provider that pins a
|
|
6968
|
+
// path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
|
|
6912
6969
|
const hookRes = runLifecycleHooks("spawn", {
|
|
6913
|
-
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
6970
|
+
home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
|
|
6914
6971
|
workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
|
|
6915
6972
|
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
|
|
6916
6973
|
});
|
|
@@ -7014,7 +7071,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
7014
7071
|
catch (e) { return retainDirectory(e); }
|
|
7015
7072
|
try {
|
|
7016
7073
|
const comp = runLifecycleHooks("retire", {
|
|
7017
|
-
home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
|
|
7074
|
+
home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
|
|
7018
7075
|
workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
|
|
7019
7076
|
priorMeta: hookRes.meta || {},
|
|
7020
7077
|
});
|
|
@@ -7213,7 +7270,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
7213
7270
|
// digests) in instance.json before this metadata is assembled — carry them.
|
|
7214
7271
|
if (o.prepared) {
|
|
7215
7272
|
try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
|
|
7216
|
-
meta.workspace = { key: o.prepared.discovery?.key ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null } };
|
|
7273
|
+
meta.workspace = { key: o.prepared.discovery?.key ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null } };
|
|
7217
7274
|
}
|
|
7218
7275
|
const spawnWarnings = warnings;
|
|
7219
7276
|
|
|
@@ -9337,9 +9394,12 @@ export function retireInstance(root, name, o = {}) {
|
|
|
9337
9394
|
const resolved = meta.capabilityRuntime
|
|
9338
9395
|
? { capabilities: meta.capabilityRuntime }
|
|
9339
9396
|
: resolveOatsConfig(meta.repo, found.agent.name);
|
|
9397
|
+
// The soul this HOME links (a workspace home links its per-commit directory;
|
|
9398
|
+
// agents/<name>/soul may since point elsewhere) — the same value spawn's hook saw.
|
|
9399
|
+
const homeSoulLink = (() => { try { return realpathSync(join(found.home, "soul")); } catch { return null; } })();
|
|
9340
9400
|
hookResults = runLifecycleHooks("retire", {
|
|
9341
9401
|
home: found.home, instance: name, agentName: found.agent.name,
|
|
9342
|
-
soulDir: found.agent._soulDir || join(found.agent._dir, "soul"),
|
|
9402
|
+
soulDir: homeSoulLink || found.agent._soulDir || join(found.agent._dir, "soul"),
|
|
9343
9403
|
contextDir: meta.repo, workspaceDir: workspaceOf(root), rootDir: root, resolved, priorMeta: meta.capabilityMeta || {},
|
|
9344
9404
|
});
|
|
9345
9405
|
}
|
|
@@ -23,7 +23,8 @@ import { loadLocal, discoverWorkspace, discoverRepo, standaloneRepo } from "./wo
|
|
|
23
23
|
import { resolveSoul, packageRef } from "./resolve.mjs";
|
|
24
24
|
import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
|
|
25
25
|
import { fetchRemoteTree } from "./remote.mjs";
|
|
26
|
-
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
|
|
26
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
27
28
|
import { randomBytes } from "node:crypto";
|
|
28
29
|
import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
|
|
29
30
|
import * as defaultRemote from "./remote.mjs";
|
|
@@ -198,6 +199,135 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
|
|
|
198
199
|
return target;
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Member clones — where a workspace soul's `work: worktree|checkout` target lives
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** The taught member name of a repo key: the last path segment without `.git`
|
|
207
|
+
* (`github.com/northwind/platform` → `platform`). The same rule `oats onboard`
|
|
208
|
+
* and `oats sync` print (memberLabel). */
|
|
209
|
+
export function memberNameOf(key) {
|
|
210
|
+
return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The convention path of a member's clone: `<deployment>/<member name>` — except a
|
|
214
|
+
* member called `agents`, which is cloned as `agents-repo/` because `<deployment>/agents/`
|
|
215
|
+
* is the instance root (design doc §4; matches onboard's cloneDirOf). */
|
|
216
|
+
export function conventionCloneDir(deployment, key) {
|
|
217
|
+
const name = memberNameOf(key);
|
|
218
|
+
return join(deployment, name === "agents" ? "agents-repo" : name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Normalise a key an operator may have WRITTEN in `clones:` — the canonical key
|
|
222
|
+
* (`github.com/org/repo`), or any ref form parseRepoRef understands (`git:…`,
|
|
223
|
+
* `https://…`, `git@host:…`, `/abs/bare.git`) — to the canonical key. A key that
|
|
224
|
+
* parses no way is returned as written (it can then only match literally). */
|
|
225
|
+
function canonicalCloneKey(written) {
|
|
226
|
+
const s = String(written).trim();
|
|
227
|
+
if (s.startsWith("local/")) return s; // a local key is already canonical
|
|
228
|
+
for (const candidate of [s, `git:${s}`]) {
|
|
229
|
+
try { return parseRepoRef(candidate).key; } catch { /* next form */ }
|
|
230
|
+
}
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The remote urls a clone carries (any remote, not only origin), parsed to their
|
|
235
|
+
* repo keys. Not a git repo → null. */
|
|
236
|
+
function cloneRemoteKeys(path) {
|
|
237
|
+
const git = (argv) => spawnSync("git", ["-C", path, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
238
|
+
const inside = git(["rev-parse", "--git-dir"]);
|
|
239
|
+
if (inside.status !== 0) return null;
|
|
240
|
+
const cfg = git(["config", "--get-regexp", "^remote\\..*\\.url$"]);
|
|
241
|
+
const keys = [];
|
|
242
|
+
if (cfg.status === 0) {
|
|
243
|
+
for (const line of cfg.stdout.split("\n")) {
|
|
244
|
+
const url = line.replace(/^\S+\s+/, "").trim();
|
|
245
|
+
if (!url) continue;
|
|
246
|
+
try { keys.push(parseRepoRef(url).key); } catch { keys.push(`?/${url}`); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return keys;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Two repo keys name the same repository. Hosted keys compare literally; `local/<abs>`
|
|
253
|
+
* keys compare by realpath when the path exists (macOS tmpdir → /private/var…). */
|
|
254
|
+
function sameRepoKey(a, b) {
|
|
255
|
+
if (a === b) return true;
|
|
256
|
+
if (!a.startsWith("local/") || !b.startsWith("local/")) return false;
|
|
257
|
+
const real = (k) => { try { return realpathSync(k.slice("local/".length)); } catch { return k.slice("local/".length); } };
|
|
258
|
+
return real(a) === real(b);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Verify `path` is the clone of `key`: it exists, is a directory, is a Git repo, and
|
|
262
|
+
* one of its remotes parses to `key`. Anything else → E_CLONE_MISMATCH { path, expected,
|
|
263
|
+
* found } — the operator pointed the kernel at the wrong place; it never works there. */
|
|
264
|
+
export function verifyMemberClone(path, key, { via } = {}) {
|
|
265
|
+
const mismatch = (found, why) => err("E_CLONE_MISMATCH", `${via ? `${via}: ` : ""}${path} is not a clone of ${key}${why ? ` (${why})` : ""}${found && found.length ? ` — its remotes point at ${found.join(", ")}` : ""}`, { path, expected: key, found, via: via ?? null });
|
|
266
|
+
let st; try { st = statSync(path); } catch { throw mismatch(null, "the path does not exist"); }
|
|
267
|
+
if (!st.isDirectory()) throw mismatch(null, "not a directory");
|
|
268
|
+
const found = cloneRemoteKeys(path);
|
|
269
|
+
if (found === null) throw mismatch(null, "not a Git repository");
|
|
270
|
+
if (!found.some((k) => sameRepoKey(k, key))) throw mismatch(found, found.length ? "no remote names it" : "it has no remotes");
|
|
271
|
+
return path;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Where the work target of a PREPARED spawn lives — the member clone of the soul's
|
|
276
|
+
* repo on this machine (design doc §4; decision 9: the work target is the only thing
|
|
277
|
+
* that needs a clone). In order:
|
|
278
|
+
* 1. `explicit` (`--repo`) — the operator's word; relative to the deployment; not
|
|
279
|
+
* verified against the member (an explicit --repo may deliberately point elsewhere);
|
|
280
|
+
* 2. `oats-local.yaml` `clones: { <repo key>: <path> }` — the key as written is
|
|
281
|
+
* normalised through parseRepoRef so `git:…`, `https://…`, `git@…` spellings match;
|
|
282
|
+
* 3. `<deployment>/<member name>` (`agents` → `agents-repo`);
|
|
283
|
+
* 4. null — nothing on this machine.
|
|
284
|
+
* A path found by (2) or (3) is VERIFIED: a directory, a Git repo, one remote parsing to
|
|
285
|
+
* the member's key → else E_CLONE_MISMATCH. A `clones:` entry whose path is absent is a
|
|
286
|
+
* mismatch too (the operator named it; "missing" would hide the typo).
|
|
287
|
+
* Returns an absolute path or null.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveMemberClone(prepared, { explicit } = {}) {
|
|
290
|
+
const deployment = resolvePath(prepared?.deployment ?? process.cwd());
|
|
291
|
+
if (typeof explicit === "string" && explicit.trim()) return isAbsolute(explicit) ? resolvePath(explicit) : resolvePath(deployment, explicit);
|
|
292
|
+
const key = prepared?.soulEntry?.repoKey;
|
|
293
|
+
if (typeof key !== "string" || !key) return null;
|
|
294
|
+
const clones = prepared?.local?.clones;
|
|
295
|
+
if (clones && typeof clones === "object") {
|
|
296
|
+
for (const [written, value] of Object.entries(clones)) {
|
|
297
|
+
if (!sameRepoKey(canonicalCloneKey(written), key)) continue;
|
|
298
|
+
if (typeof value !== "string" || !value.trim()) throw err("E_CLONE_MISMATCH", `oats-local.yaml clones: ${written} must be a path`, { path: value, expected: key, found: null, via: "oats-local.yaml clones:" });
|
|
299
|
+
const path = isAbsolute(value) ? resolvePath(value) : resolvePath(deployment, value);
|
|
300
|
+
return verifyMemberClone(path, key, { via: `oats-local.yaml clones: ${written}` });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const convention = conventionCloneDir(deployment, key);
|
|
304
|
+
if (!existsSync(convention)) return null;
|
|
305
|
+
return verifyMemberClone(convention, key, { via: "convention path" });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The clone url of the soul's member as the workspace names it (for remedies). */
|
|
309
|
+
function memberUrlOf(prepared, key) {
|
|
310
|
+
for (const ref of prepared?.discovery?.workspace?.members || []) {
|
|
311
|
+
try { const p = parseRepoRef(ref); if (sameRepoKey(p.key, key)) return p.url; } catch { /* validated already */ }
|
|
312
|
+
}
|
|
313
|
+
if (prepared?.discovery?.standalone === true && prepared.discovery.key === key) return prepared.discovery.url ?? null;
|
|
314
|
+
return key.startsWith("local/") ? key.slice("local/".length) : `https://${key}.git`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** resolveMemberClone, or E_CLONE_MISSING naming BOTH ways to provide the clone
|
|
318
|
+
* (the convention path and a `clones:` entry) plus `--repo` for a one-off. */
|
|
319
|
+
export function requireMemberClone(prepared, { explicit } = {}) {
|
|
320
|
+
const found = resolveMemberClone(prepared, { explicit });
|
|
321
|
+
if (found) return found;
|
|
322
|
+
const key = prepared?.soulEntry?.repoKey ?? "<repo>";
|
|
323
|
+
const deployment = resolvePath(prepared?.deployment ?? process.cwd());
|
|
324
|
+
const convention = conventionCloneDir(deployment, key);
|
|
325
|
+
const url = memberUrlOf(prepared, key);
|
|
326
|
+
const soul = prepared?.soulEntry?.name ?? "<soul>";
|
|
327
|
+
const work = prepared?.soulEntry?.definition?.work ?? "worktree";
|
|
328
|
+
throw err("E_CLONE_MISSING", `soul ${soul} works in a ${work} of ${key}, and this machine has no clone of it — either \`git clone ${url} ${convention}\` (the convention: <deployment>/<member name>) or point oats-local.yaml at an existing clone: \`clones: { ${key}: <abs path> }\`; for a one-off pass --repo <path>`, { soul, repoKey: key, work, deployment, convention, url, remedies: { clone: `git clone ${url} ${convention}`, local: { clones: { [key]: "<abs path>" } }, flag: "--repo <path>" } });
|
|
329
|
+
}
|
|
330
|
+
|
|
201
331
|
/** The async half of a spawn: everything that touches the network. Returns a
|
|
202
332
|
* PREPARED object that `spawnInstance` consumes synchronously. */
|
|
203
333
|
export async function prepareInstance(contextDir, soulName, { spawn = {}, remoteOptions, remote, local: localOverride, discovery: discoveryOverride } = {}) {
|
|
@@ -208,7 +338,7 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
208
338
|
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
|
|
209
339
|
const soulEntry = findSoulEntry(discovery, soulName);
|
|
210
340
|
const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
|
|
211
|
-
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions };
|
|
341
|
+
return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
|
|
212
342
|
}
|
|
213
343
|
|
|
214
344
|
/** E_REMOTE_UNREADABLE reasons (lib/remote.mjs classifyRemoteFailure) that mean "the
|