@awebai/oats 0.30.1 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -2823,7 +2823,7 @@ function versionCmd() {
2823
2823
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2824
2824
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2825
2825
  // never listed.
2826
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2826
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2827
2827
  return;
2828
2828
  }
2829
2829
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -28,7 +28,7 @@ A capability lives in one of two kinds of source:
28
28
  A soul says `capabilities: { <name>: { from: here | <repo key> | package } }`
29
29
  (or `off`); the workspace supplies defaults. At spawn every resolved
30
30
  capability is **copied whole** into the instance (`<home>/.oats/modules/<name>/`,
31
- skills into `<home>/.agents/skills/<name>/`), and the instance's `AGENTS.md` is
31
+ its skills flat into `<home>/.agents/skills/<skill>/`), and the instance's `AGENTS.md` is
32
32
  generated without changing the canonical soul. Nothing is installed or
33
33
  activated at a deployment.
34
34
 
@@ -237,8 +237,8 @@ live.
237
237
 
238
238
  Every spawned instance gets a **full copy** of each capability its soul
239
239
  resolved to, under `<home>/.oats/modules/<capability>/` (manifest, `bin/`,
240
- injects, skills), and those skills under
241
- `<home>/.agents/skills/<capability>/<skill>/`. Its generated `AGENTS.md` is the
240
+ injects, skills), and those skills flat under `<home>/.agents/skills/<skill>/`
241
+ beside the soul's own, one level deep where harnesses discover them. Its generated `AGENTS.md` is the
242
242
  soul's `AGENTS.md`, the kernel and work-mode blocks, then each module's inject
243
243
  in name order. Two composed skills with one name fail the spawn
244
244
  (`E_SKILL_DUPLICATE`). The harness then starts normally, with its own skill
@@ -286,7 +286,8 @@ shims that throw `E_REMOVED { name, contract }`, pointing at this record.
286
286
  must match the lock's commit (`E_MATERIALIZE_INTEGRITY { why: "lock" }`); an unknown repo is
287
287
  `E_MATERIALIZE_SOURCE`.
288
288
  2. The copy's digest must equal the fetch's and any `module.digest` (`E_MATERIALIZE_INTEGRITY`).
289
- 3. Copy skills whole to `<home>/.agents/skills/<name>/<skill>/`.
289
+ 3. Copy skills whole to `<home>/.agents/skills/<skill>/`, flat (0.30.2; the grouped
290
+ `<name>/<skill>/` layout this section first specified hid every skill from the harnesses).
290
291
  4. Compose `<home>/AGENTS.md` = soul body (`options.soulAgentsMd` or `soulDir`) + kernel blocks + module
291
292
  injects; operating guidance comes from a module such as `oats.core`. Keep `CLAUDE.md → AGENTS.md`
292
293
  and `.claude/skills → ../.agents/skills`.
@@ -36,7 +36,8 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
36
36
  "instance-git-remote","souls-declarations","lifecycle-plans","retire-retention","readiness","spawn-preview","instance-events",
37
37
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
38
38
  "workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
39
- "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference"],
39
+ "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
40
+ "preview-composed-from"],
40
41
  "automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
41
42
  "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2}
42
43
  ```
@@ -88,6 +89,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
88
89
  | `automations` | workspace triggers and schedules; `oats automations refresh` | `automationsApi: 1` |
89
90
  | `desktop-facts` | the facts under [Desktop facts](#desktop-facts-feature-desktop-facts-oats-0290) | |
90
91
  | `launch-preference` | soul and local launch preferences; `launch`, `launchCurrent`, `launchFrom`; `--reselect-launch`; `key` on soul and agent rows ([Launch preferences](#soul-launch-preferences-feature-launch-preference-oats-0300)) | |
92
+ | `preview-composed-from` | `composedFrom` on preview `modules[]` ([Composition](#the-preview)) | |
91
93
 
92
94
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
93
95
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -1073,9 +1075,9 @@ it to a temporary copy (`soulFetched: true`).
1073
1075
  ```json
1074
1076
  {"modules":[
1075
1077
  {"name":"nw-tools","from":{"kind":"member","repoKey":"github.com/nw/agents","commit":"66566512…"},"layer":null,"private":false,"declares":[],
1076
- "changedSince":{"instance":"rm-2","was":"45b86f64…"}},
1078
+ "changedSince":{"instance":"rm-2","was":"45b86f64…"},"composedFrom":"soul"},
1077
1079
  {"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1078
- "layer":"knowledge","private":false,"declares":["state-dir"],"changedSince":false}],
1080
+ "layer":"knowledge","private":false,"declares":["state-dir"],"changedSince":false,"composedFrom":"workspace"}],
1079
1081
  "teams":[{"label":"eng","team":"eng:nw.aweb.ai","default":true,"from":"shared"}],
1080
1082
  "defaultTeam":{"label":"eng","team":"eng:nw.aweb.ai","from":"soul"},
1081
1083
  "resolution":"abacbdb5a7975098d77007c8","declRevision":"068a0d3f1311a9e84e9aff2e","payloadRevision":"81a368006c610194aa35dbe0",
@@ -1124,9 +1126,18 @@ it to a temporary copy (`soulFetched: true`).
1124
1126
 
1125
1127
  **Composition.**
1126
1128
  - `modules[]` (feature `instance-modules`): `{name, from, layer, private,
1127
- declares, changedSince}`; `from` is what `instance.json` will record.
1128
- `changedSince` is `null` (no previous instance), `false` (unchanged since the
1129
- newest one) or `{instance, was}`.
1129
+ declares, changedSince, composedFrom}`; `from` is what `instance.json` will
1130
+ record. `changedSince` is `null` (no previous instance), `false` (unchanged
1131
+ since the newest one) or `{instance, was}`.
1132
+ - `composedFrom` (feature `preview-composed-from`, OATS 0.30.2): why the
1133
+ module is there — `"soul"` (the soul declares it, including a soul entry
1134
+ that overrides a workspace default of the same name, a package soul's
1135
+ `from: here`, and every module of a standalone view) or `"workspace"` (a
1136
+ `defaults.<slot>` or `defaults.capabilities` entry). It is the same value
1137
+ `oats inspect --soul` reports as `capabilities[].composedFrom`. It is
1138
+ provenance only: it never enters `resolution`, `declRevision`,
1139
+ `payloadRevision` or `decision.revision`, and `instance.json` does not
1140
+ record it. `from` says where the bytes come from.
1130
1141
  - `capabilities[]` (`{name, origin}`, `origin` `package:<id>@<v>` or
1131
1142
  `member:<repoKey>@<commit>`) and `skills[]` (the soul's own skills as
1132
1143
  strings, module skills as `{name, source: "module:<cap>"}`) are display
@@ -1288,8 +1299,9 @@ the keyed-spawn fields `decision`, `spawnIdempotencyKey`, `spawnCompleted` and
1288
1299
  `wake`; later starts add `restarts` and `restartCount`.
1289
1300
 
1290
1301
  - `modules.<cap>`: `{from, commit, digest, materializedAt}`; `digest` hashes
1291
- the copy at `<home>/.oats/modules/<cap>/`. Module skills are copied to
1292
- `<home>/.agents/skills/<cap>/<skill>/`.
1302
+ the copy at `<home>/.oats/modules/<cap>/`. Module skills are copied flat to
1303
+ `<home>/.agents/skills/<skill>/` (homes spawned by 0.30.1 or earlier keep
1304
+ `<home>/.agents/skills/<cap>/<skill>/`).
1293
1305
  - `providers.<cap>`: the merged payload (`{}` when none).
1294
1306
  - `workspace`: `{key, name, deployment, commit, resolution, standalone, soul,
1295
1307
  layers}`. `name` is recorded, and every hook, command and operation of the
@@ -120,7 +120,7 @@ oats status
120
120
  `--harness pi|claude|codex` picks the harness; complete any native folder
121
121
  trust or authentication prompt in the printed session. The instance home is
122
122
  `agents/<soul>/instances/<instance>/`; `work/` is its repository view;
123
- `.oats/modules/<cap>/` and `.agents/skills/<cap>/` are the copied capabilities;
123
+ `.oats/modules/<cap>/` are the copied capabilities and `.agents/skills/<skill>/` their skills;
124
124
  `instance.json` records `modules` (from, commit, digest), `providers` and
125
125
  `workspace`. A running instance never changes under itself — a member that
126
126
  moves affects only new spawns, and `oats status` shows the drift
@@ -29,7 +29,7 @@ an author soul:
29
29
  ```yaml
30
30
  # oats-workspace.yaml
31
31
  packages:
32
- oats.framework: v1.4.0 # provides oats.core, oats.setup, oats.knowledge-theory
32
+ oats.framework: v1.4.1 # provides oats.core, oats.setup, oats.knowledge-theory
33
33
 
34
34
  # souls/<author-soul>/soul.yaml
35
35
  capabilities:
@@ -9,7 +9,7 @@ or workspace membership alone does not make a package official.
9
9
 
10
10
  | package | release | capabilities | package souls |
11
11
  |---|---|---|---|
12
- | `oats.framework` | `oats-framework/v1.4.0` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
12
+ | `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
13
  | `oats.okf` | `v4.0.5` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
14
  | `oats.aweb` | `v1.17.1` | `oats.aweb` (messaging) | |
15
15
  | `oats.engineering` | `v1.3.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
package/docs/packages.md CHANGED
@@ -51,7 +51,7 @@ packages:
51
51
  - **Bare version** (`v4.0.5`, `4.0.5`, `1.0.0-rc.1`): the id is looked up in
52
52
  the official catalog — `package-catalog.json` in the `oats` repo, or the file
53
53
  named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
54
- convention (`v4.0.5` or `oats-framework/v1.4.0`) and the payload path. An id
54
+ convention (`v4.0.5` or `oats-framework/v1.4.1`) and the payload path. An id
55
55
  the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
56
56
  a package outside the catalog"). The catalog is the reviewed official list
57
57
  ([official-catalog.md](official-catalog.md)) and the only way a
@@ -74,7 +74,7 @@ members:
74
74
  - git:github.com/acme/agents
75
75
  - git:github.com/acme/platform
76
76
  packages:
77
- oats.framework: v1.4.0
77
+ oats.framework: v1.4.1
78
78
  oats.okf: v4.0.5
79
79
  oats.aweb: v1.17.1
80
80
  teams:
@@ -212,7 +212,7 @@ At spawn a `from: package` module is fetched at the lock's commit from the
212
212
  lock's `url`, at the manifest-listed directory (`oats-package.json#capabilities[]`
213
213
  entry), into `<home>/.oats/modules/<cap>/`; the copy's digest is verified
214
214
  against what the fetch reported; skills are copied to
215
- `<home>/.agents/skills/<cap>/<skill>/`. `instance.json.modules.<cap>.from` is
215
+ `<home>/.agents/skills/<skill>/` (flat). `instance.json.modules.<cap>.from` is
216
216
  `{ kind: "package", package, version, commit, integrity, repoKey }`. Bumping
217
217
  `packages:` and syncing affects **only new spawns**; `oats status` shows a
218
218
  running instance's package module as `moved` once the lock points elsewhere.
@@ -332,11 +332,11 @@ A soul that names one of the package's capabilities with
332
332
  "policy": "docs/official-catalog.md",
333
333
  "packages": {
334
334
  "oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.5", "path": "oats-package" },
335
- "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.0", "path": "oats-package" }
335
+ "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.1", "path": "oats-package" }
336
336
  }
337
337
  }
338
338
  ```
339
339
 
340
- `ref` carries the tag convention: a workspace's `oats.framework: v1.4.0`
341
- resolves to tag `oats-framework/v1.4.0`. Resolving through the catalog never
340
+ `ref` carries the tag convention: a workspace's `oats.framework: v1.4.1`
341
+ resolves to tag `oats-framework/v1.4.1`. Resolving through the catalog never
342
342
  advances a lock by itself: `oats sync` does, and says so.
@@ -0,0 +1,85 @@
1
+ # OATS 0.30.2
2
+
3
+ ## Changed
4
+
5
+ - **Desktop: a faster, calmer load.** The backend reads a deployment's status
6
+ and workspace status in parallel, holds the souls and capabilities catalogs
7
+ and soul inspections server-side (re-read when the workspace state or a
8
+ Desktop mutation moves, or when viewed after 60 s), and slows its cycle
9
+ from 5 s to 30 s while no window is focused. Returning to the window no longer
10
+ re-reads the capabilities list or cancels the observation in flight. When
11
+ the installed CLI supports `--max-age`, background reads accept the kernel's
12
+ recent observation; `/api/panel`, `/api/agents`, `/api/workspace-sync` and
13
+ `/api/capabilities` inspect now report `observedAt` and `refreshing`.
14
+ - **Desktop: Where it works shows the work folder.** The Instance tab's Where
15
+ it works card adds a Folder row, `<home>/work`, between Branch and Home, with
16
+ a copy button; in checkout, attached and workspace mode a muted "shared" tag
17
+ says it links to the tree other instances share.
18
+
19
+ - **Desktop: consistent loading states.** The sidebar roster, the hierarchy,
20
+ the soul inspector and readiness now run on one state model: skeletons
21
+ shaped like the final content instead of false empty lists,
22
+ content and focus kept while refreshing, stale data labelled with its
23
+ observation's age and a Retry, and the actions that need current state held
24
+ with an accessible reason until a good read.
25
+
26
+ - **Desktop: the Workspace view and the instance panel join the loading-state
27
+ model.** The Souls grid, the Capabilities tab and its capability pages, and
28
+ the instance panel's Soul and Messaging sections show skeletons at the real
29
+ row and card size instead of empty claims, keep their content (and a held
30
+ table) through a refresh or a failed re-read, and name a stale observation's
31
+ age with a Retry; the spawn dialog's harness, model and launch hints describe
32
+ the preview for the choices on screen only.
33
+ - **The spawn preview says why each module is there.** Every `modules[]` row
34
+ of `oats spawn … --preview --json` carries `composedFrom`: `"soul"` when the
35
+ soul declares the capability (a package soul's `from: here` and every module
36
+ of a standalone view included), `"workspace"` when a workspace default
37
+ (`defaults.<slot>` or `defaults.capabilities`) gave it. It is the value
38
+ `oats inspect --soul` already reports as `capabilities[].composedFrom`.
39
+ Provenance only: `resolution`, `declRevision`, `payloadRevision` and the
40
+ spawn decision are unchanged, so a previewed decision still binds its apply.
41
+ Gate on the new feature `preview-composed-from`
42
+ ([Desktop CLI API](../desktop-cli-api.md#the-preview)).
43
+
44
+ ## Fixed
45
+
46
+ - **Instance skills are flat again, so Claude Code finds them.** Since the
47
+ workspace model (0.25.0) spawn copied each module's skills grouped by
48
+ capability, `<home>/.agents/skills/<capability>/<skill>/SKILL.md`. Claude
49
+ Code discovers skills one level deep (`.claude/skills/<skill>/SKILL.md`), so
50
+ no capability skill resolved there: `/oats-operate`, `/oats-aweb`,
51
+ `/okf-consultation` and the rest were missing from the session although the
52
+ composed `AGENTS.md` tells the agent to load them. (pi's loader recurses, so
53
+ pi instances were not affected.) Spawn now copies every skill, the soul's
54
+ own and every module's, flat to `<home>/.agents/skills/<skill>/`, the
55
+ layout the Agent Skills convention and every harness expect;
56
+ `.claude/skills → ../.agents/skills` is unchanged. `instance.json` records
57
+ the flat paths (`capabilities[].skills`; a module's skill tree in
58
+ `composition.expected[].resolved` is the skills root).
59
+ - **One skill name, one directory.** Every composed skill shares the flat
60
+ directory, so names are unique across the soul and its modules, compared
61
+ case-insensitively: a soul's own skill named like a module's skill, or two
62
+ names differing only in case, is `E_SKILL_DUPLICATE` at spawn and the home
63
+ is removed. A soul skill named like a *capability* (`oats.okf`) is no
64
+ longer a collision. No soul in this repository is affected.
65
+ - **oats.framework 1.4.1** (oats.core 2.2.1): the `oats-operate` skill shows
66
+ the flat layout.
67
+
68
+ **Existing Claude Code instances keep their grouped layout and still do not
69
+ see their capability skills: respawn them to pick up the fix.** Nothing
70
+ rewrites an existing home: a running instance never changes under itself, and
71
+ moving its skills would contradict what its `instance.json` recorded. Until
72
+ it is respawned, an agent can read a skill by path,
73
+ `.agents/skills/<capability>/<skill>/SKILL.md`.
74
+
75
+ ## Still known in 0.30.2
76
+
77
+ The 0.30.1 notes planned the first two below for 0.30.2. They are not in this
78
+ release; they are planned for 0.30.3.
79
+
80
+ - With no teams configured, a spawn is refused by the provider
81
+ (`E_SPAWN_FAILED`, relaying "no teams configured: run `oats aweb setup`"),
82
+ not by the kernel's `E_TEAM_UNCONFIGURED`.
83
+ - A capability operator command run from a deployment needs `--soul <soul>`.
84
+ - On a team you control (BYOT), retire cannot revoke membership; the retire
85
+ prints the owner's `aw id team remove-member` command, as in 0.30.0.
@@ -122,8 +122,8 @@ full copy** of every capability the soul resolved to:
122
122
  <agents-root>/<soul>/instances/<instance>/
123
123
  AGENTS.md # generated: soul AGENTS.md + kernel/work-mode blocks + each module's inject
124
124
  CLAUDE.md → AGENTS.md
125
- .agents/skills/ # canonical skill tree — soul skills + <capability>/<skill>/ full copies
126
- <capability>/<skill>/SKILL.md
125
+ .agents/skills/ # canonical skill tree, flat: the soul's skills and every module's, full copies
126
+ <skill>/SKILL.md # one level deep, where every harness discovers skills
127
127
  .claude/skills → ../.agents/skills
128
128
  .oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
129
129
  work/ # worktree, checkout symlink, attached tree, or private directory
@@ -193,7 +193,8 @@ bump affects only new spawns.
193
193
  OATS is a skill contributor, not a skill sandbox. The harness (pi, Claude Code,
194
194
  Codex) starts with cwd = the instance home and its **own** skill discovery
195
195
  intact: it sees, nearest first, the instance's `.agents/skills/` (soul skills
196
- and the copied capability skills), the repo's own `.agents/skills/` once it
196
+ and the copied capability skills, all flat at `.agents/skills/<skill>/SKILL.md`,
197
+ the one level deep Claude Code discovers through `.claude/skills`), the repo's own `.agents/skills/` once it
197
198
  works in `work/`, and whatever the operator keeps at machine level. All three
198
199
  are intended. Two *composed* skills with one name is a spawn error naming both
199
200
  capabilities (`E_SKILL_DUPLICATE`); a composed skill versus an ambient one is
@@ -54,7 +54,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
54
54
  - git:github.com/acme/tools # a member that ALSO publishes a package (see below)
55
55
 
56
56
  packages: # the ONLY versioned things
57
- oats.framework: v1.4.0 # bare version → resolves through the official catalog
57
+ oats.framework: v1.4.1 # bare version → resolves through the official catalog
58
58
  oats.okf: v4.0.5
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
package/lib/core.mjs CHANGED
@@ -2992,7 +2992,7 @@ function* spawnBody(root, agent, o = {}) {
2992
2992
  return e;
2993
2993
  };
2994
2994
  // Workspace model: copy every resolved capability WHOLE into the new home
2995
- // (.oats/modules/<cap>/ + .agents/skills/<cap>/) and record modules/providers
2995
+ // (.oats/modules/<cap>/, its skills flat in .agents/skills/<skill>/) and record modules/providers
2996
2996
  // in instance.json. Then REBUILD the capability rows against the copies that
2997
2997
  // landed (H1): until now `resolvedCfg.capabilities` were PLANNED rows (no
2998
2998
  // skills, no inject, no dir) — hooks, environment, requirements, retirement
@@ -3034,9 +3034,14 @@ function* spawnBody(root, agent, o = {}) {
3034
3034
  const row = rows.find((c) => c.id === r.module);
3035
3035
  if (r.type === "injection") { r.path = row?.inject; continue; }
3036
3036
  if (r.type === "skill-tree") {
3037
- const skillsRoot = join(home, ".agents", "skills", r.module);
3037
+ // Module skills land flat in the canonical skills root; the entries are
3038
+ // the names materialize placed for this module (what the resolution
3039
+ // promised), verified against the root in the completeness check.
3040
+ const skillsRoot = join(home, ".agents", "skills");
3038
3041
  r.path = existsSync(skillsRoot) ? skillsRoot : undefined;
3039
- r.entries = (row?.skills || []).map((p) => basename(p));
3042
+ r.entries = Array.isArray(materializeOutcome?.skills)
3043
+ ? materializeOutcome.skills.filter((s) => s.module === r.module).map((s) => s.name)
3044
+ : (row?.skills || []).map((p) => basename(p));
3040
3045
  }
3041
3046
  }
3042
3047
  } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
@@ -3074,30 +3079,43 @@ function* spawnBody(root, agent, o = {}) {
3074
3079
  }
3075
3080
  if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
3076
3081
 
3077
- // Skills: module skills were copied WHOLE into <home>/.agents/skills/<module>/
3082
+ // Skills: module skills were copied WHOLE and FLAT into <home>/.agents/skills/<skill>/
3078
3083
  // by materialize (decision 7/13); here only the soul's own skills join them, one
3079
- // directory each. There is no override: a soul skill named like a module's
3080
- // directory is a duplicate (decision 16). The harness is launched with its normal
3084
+ // directory each, at the same level — the one level every harness discovers. There
3085
+ // is no override: a soul skill named like a module's skill is a duplicate (decision 16). The harness is launched with its normal
3081
3086
  // discovery — the machine's and the repo's skills are the harness's business.
3082
3087
  const sources = [];
3083
3088
  const soulSkills = join(soulDir, "skills");
3084
3089
  if (existsSync(soulSkills)) sources.push({ id: "soul", path: soulSkills });
3085
3090
  const chosen = new Map();
3091
+ // Names compare case-insensitively: one directory per skill on a case-insensitive
3092
+ // filesystem (APFS, NTFS) must not silently merge `Foo` into `foo`.
3093
+ const moduleSkillOwner = new Map((materializeOutcome?.skills || []).map((s) => [s.name.toLowerCase(), s.module]));
3094
+ const chosenKeys = new Map();
3086
3095
  const offer = (name, src, source) => {
3087
- if (chosen.has(name) || existsSync(join(home, ".agents", "skills", name))) throw oatsError("E_SKILL_DUPLICATE", `skill "${name}" from ${source} collides with ${chosen.get(name)?.source ?? `module ${name}'s skill directory`}`);
3096
+ const key = name.toLowerCase();
3097
+ if (chosenKeys.has(key) || moduleSkillOwner.has(key) || existsSync(join(home, ".agents", "skills", name))) {
3098
+ const other = chosenKeys.get(key) ?? (moduleSkillOwner.has(key) ? `module ${moduleSkillOwner.get(key)}'s skill` : `the existing .agents/skills/${name}`);
3099
+ throw oatsError("E_SKILL_DUPLICATE", `skill "${name}" from ${source} collides with ${other}`);
3100
+ }
3088
3101
  chosen.set(name, { src, source });
3102
+ chosenKeys.set(key, source);
3089
3103
  };
3090
- // Same enumerator preflight used, so "what a tree promises" and "what gets
3091
- // copied" cannot drift apart.
3092
- for (const source of sources) for (const entry of skillEntriesIn(source.path)) offer(entry.name, entry.src, source.id);
3093
- mkdirSync(join(home, ".agents", "skills"), { recursive: true });
3094
- mkdirSync(join(home, ".claude"), { recursive: true });
3095
- for (const [name, selected] of [...chosen].sort(([a], [b]) => a.localeCompare(b))) {
3096
- // Pi's recursive skill scanner does not descend through directory symlinks.
3097
- // Copy each selected tree so the exact instance-local set is real and immutable.
3098
- copyTreeSafe(realpathSync(selected.src), join(home, ".agents", "skills", name));
3099
- }
3100
- if (!existsSync(join(home, ".claude", "skills"))) symlinkSync(join("..", ".agents", "skills"), join(home, ".claude", "skills"));
3104
+ // A refusal here comes after materialize populated the home: remove it whole,
3105
+ // like every other failure between materialize and the completeness check.
3106
+ try {
3107
+ // Same enumerator preflight used, so "what a tree promises" and "what gets
3108
+ // copied" cannot drift apart.
3109
+ for (const source of sources) for (const entry of skillEntriesIn(source.path)) offer(entry.name, entry.src, source.id);
3110
+ mkdirSync(join(home, ".agents", "skills"), { recursive: true });
3111
+ mkdirSync(join(home, ".claude"), { recursive: true });
3112
+ for (const [name, selected] of [...chosen].sort(([a], [b]) => a.localeCompare(b))) {
3113
+ // Pi's recursive skill scanner does not descend through directory symlinks.
3114
+ // Copy each selected tree so the exact instance-local set is real and immutable.
3115
+ copyTreeSafe(realpathSync(selected.src), join(home, ".agents", "skills", name));
3116
+ }
3117
+ if (!existsSync(join(home, ".claude", "skills"))) symlinkSync(join("..", ".agents", "skills"), join(home, ".claude", "skills"));
3118
+ } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3101
3119
 
3102
3120
  // EXPECTED == MATERIALIZED. Preflight proved every declared resource resolves;
3103
3121
  // this proves the copies actually landed, so "the composition is complete" is
@@ -3119,18 +3137,14 @@ function* spawnBody(root, agent, o = {}) {
3119
3137
  if (!chosen.has(name)) incomplete.push(`skill "${name}", promised by ${r.source} (${r.declared}), is missing from the composed set`);
3120
3138
  }
3121
3139
  }
3122
- // Prepared spawn: every module's declared skill must have been copied under
3123
- // .agents/skills/<module>/<skill>/ by materialize — verify the copies landed
3124
- // as readable skills (the module directory alone proves nothing).
3140
+ // Prepared spawn: every module's declared skill must have been copied to
3141
+ // .agents/skills/<skill>/ by materialize — verify the copies landed as
3142
+ // readable skills, one level deep, where the harnesses discover them.
3125
3143
  if (o.prepared) {
3126
- for (const m of o.prepared.resolution.modules) for (const s of m.manifest.skills || []) {
3127
- const sk = join(home, ".agents", "skills", m.name);
3128
- if (!existsSync(sk)) { incomplete.push(`module "${m.name}" declares skills but .agents/skills/${m.name} is absent`); break; }
3129
- }
3130
3144
  for (const r of expectedResources) {
3131
3145
  if (r.deferred !== "materialize" || r.type !== "skill-tree") continue;
3132
- if (!r.path) { incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but .agents/skills/${r.module} is absent`); continue; }
3133
- if (!r.entries.length) incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but no skill was copied under .agents/skills/${r.module}`);
3146
+ if (!r.path) { incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but .agents/skills is absent`); continue; }
3147
+ if (!r.entries.length) incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but none of its skills was copied into .agents/skills`);
3134
3148
  for (const name of r.entries) if (!hasSkillDoc(join(r.path, name))) incomplete.push(`skill "${name}" (module ${r.module}) did not materialize as a readable SKILL.md`);
3135
3149
  }
3136
3150
  }
@@ -3475,7 +3489,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3475
3489
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3476
3490
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
3477
3491
 
3478
- // Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
3492
+ // Module skills as materialize landed them (flat, .agents/skills/<skill>/),
3479
3493
  // beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
3480
3494
  // `from` = the home's module copy the skill was copied from.
3481
3495
  const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
@@ -221,7 +221,7 @@ function capabilityRows(t) {
221
221
  return { ...op, argv: [m.command ?? null, op.command], available: !reason, reason };
222
222
  });
223
223
  // composedFrom (feature desktop-facts): where the soul's composition took it from — "workspace" |
224
- // "team:<label>" | "soul" (layers-from vocabulary); null for a home (not recorded at spawn). `from` is the module's origin.
224
+ // "soul" (layers-from vocabulary); null for a home (not recorded at spawn). `from` is the module's origin.
225
225
  return { id: name, version: m.version ?? null, layer: m.layer ?? null, command: m.command ?? null, from, composedFrom: t.capabilitiesFrom?.[name] ?? null, dir,
226
226
  settings: obj(t.payloads[name]) ? t.payloads[name] : {}, declares: declaredSettings(m), compatibility: compatibilityRow(name, m), missingRequires: missing, operations };
227
227
  });
@@ -5,7 +5,7 @@
5
5
  * its Git remotes in the operator's access context → find the soul among the
6
6
  * confirmed members (or external souls) → resolve every capability by `from:`
7
7
  * (member = latest state, package = the locked version) → materialize
8
- * each capability WHOLE into the new home (`.oats/modules/`, `.agents/skills/`) →
8
+ * each capability WHOLE into the new home (`.oats/modules/`, skills flat in `.agents/skills/`) →
9
9
  * compose AGENTS.md → launch the harness normally.
10
10
  *
11
11
  * This module owns the async half (discover + resolve) and the materialize call;
@@ -24,7 +24,7 @@ import { resolveSoul, kernelCompatibility } from "./resolve.mjs";
24
24
  import { BY_TEAM_REMOVED, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
25
25
  import { launchLayers } from "./launch-preference.mjs";
26
26
  import { declaredSettings } from "./capability-contract.mjs";
27
- import { materialize, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
27
+ import { materialize, moduleSkills, MODULES_DIR, SKILLS_DIR } from "./materialize.mjs";
28
28
  import { fetchRemoteTree } from "./remote.mjs";
29
29
  import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync, statSync } from "node:fs";
30
30
  import { tmpdir } from "node:os";
@@ -524,9 +524,11 @@ export function toCapabilityRows(resolution, home) {
524
524
  for (const m of resolution.modules) {
525
525
  const dir = join(home, MODULES_DIR, m.name);
526
526
  const manifest = m.manifest;
527
- const skills = [];
528
- const skillsRoot = join(home, SKILLS_DIR, m.name);
529
- if (existsSync(skillsRoot)) for (const e of readdirSync(skillsRoot, { withFileTypes: true })) if (e.isDirectory()) skills.push(join(skillsRoot, e.name));
527
+ // Skills are flat under <home>/.agents/skills/<skill>/; which of them this module
528
+ // contributed is read from its own copy, enumerated exactly as materialize placed them.
529
+ const skills = existsSync(dir)
530
+ ? moduleSkills(resolution, m, dir).map((s) => join(home, SKILLS_DIR, s.name)).filter((p) => existsSync(p))
531
+ : [];
530
532
  const inject = manifest.inject ? join(dir, manifest.inject) : undefined;
531
533
  rows.push({
532
534
  id: m.name, capability: m.name, manifest, layer: manifest.layer ?? undefined, command: manifest.command,
@@ -564,8 +566,12 @@ function requiredHooksOf(manifest) {
564
566
  return Object.entries(hooks).filter(([, s]) => s && typeof s === "object" && s.required === true).map(([e]) => e);
565
567
  }
566
568
 
567
- /** What a preview shows about modules: from/commit/digest per module and whether
568
- * it changed since the newest existing instance of the same soul in `agentsRoot`. */
569
+ /** What a preview shows about modules: from/commit/digest per module, whether
570
+ * it changed since the newest existing instance of the same soul in `agentsRoot`,
571
+ * and `composedFrom` (feature preview-composed-from): why the soul's composition
572
+ * has it — "soul" | "workspace", the resolution's capabilitiesFrom. Provenance
573
+ * only: it is outside every fingerprint. `null` only for a resolution without
574
+ * capabilitiesFrom (an older in-process caller); never for a resolved module. */
569
575
  export function modulesPreview(resolution, agentsRoot, soulName) {
570
576
  let previous = null;
571
577
  const instancesDir = join(agentsRoot, soulName, "instances");
@@ -583,7 +589,8 @@ export function modulesPreview(resolution, agentsRoot, soulName) {
583
589
  return resolution.modules.map((m) => {
584
590
  const prev = previous?.modules?.[m.name];
585
591
  const changedSince = !previous ? null : !prev ? { instance: previous.name, was: null } : (prev.commit !== m.from.commit ? { instance: previous.name, was: prev.commit } : false);
586
- return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, declares: declaredSettings(m.manifest), changedSince };
592
+ return { name: m.name, from: m.from, layer: m.manifest.layer ?? null, private: !!m.private, declares: declaredSettings(m.manifest), changedSince,
593
+ composedFrom: resolution.capabilitiesFrom?.[m.name] ?? null };
587
594
  });
588
595
  }
589
596
 
@@ -6,7 +6,9 @@
6
6
  * materialize(resolution, home, options)
7
7
  * For every module of the resolution: fetch its capability directory whole into
8
8
  * <home>/.oats/modules/<name>/, verify the copy's content digest, copy its skills as
9
- * FULL copies into <home>/.agents/skills/<name>/<skill>/, compose <home>/AGENTS.md
9
+ * FULL copies into <home>/.agents/skills/<skill>/ — FLAT, one level deep, where every
10
+ * harness discovers them (Claude Code through .claude/skills/<skill>/); skill names are
11
+ * unique across the modules (E_SKILL_DUPLICATE) — compose <home>/AGENTS.md
10
12
  * (soul body + each module's inject, same marker comments as the kernel composer),
11
13
  * keep the CLAUDE.md / .claude/skills aliases, and record modules + providers in
12
14
  * <home>/instance.json.
@@ -16,7 +18,7 @@
16
18
  * place. Any failure before the commit removes the staging directory and leaves the home
17
19
  * exactly as it was (a `.oats/` directory created only for staging is removed too). The
18
20
  * commit re-checks the home's shape (no symlink planted at .oats/.agents/.claude or the
19
- * module targets during the fetch), then runs a short sequence of renames — modules,
21
+ * module and skill targets during the fetch), then runs a short sequence of renames — modules,
20
22
  * skills, AGENTS.md, instance.json, and the CLAUDE.md / .claude/skills aliases — with a
21
23
  * rollback journal: should any step fail, everything already placed is undone and the
22
24
  * previous AGENTS.md / instance.json restored before a named error (E_MATERIALIZE_HOME)
@@ -205,7 +207,7 @@ function hasSkillDoc(dir) {
205
207
  /** The skills a module contributes → [{ name, path }] (path relative to the module root).
206
208
  * Precedence: resolution.skills rows for the module; else manifest.skills; else every
207
209
  * skills/<dir>/SKILL.md in the fetched tree. */
208
- function moduleSkills(resolution, module, moduleDir) {
210
+ export function moduleSkills(resolution, module, moduleDir) {
209
211
  const declared = (resolution.skills || []).filter((s) => s && s.module === module.name);
210
212
  const rows = [];
211
213
  if (declared.length) {
@@ -335,13 +337,22 @@ export async function materialize(resolution, home, options = {}) {
335
337
  const parents = [join(homeAbs, ".oats"), modulesRoot, join(homeAbs, ".agents"), skillsRoot, join(homeAbs, ".claude")];
336
338
  /** The home must still be what we checked: module targets absent, every parent a real directory (or absent).
337
339
  * Run before the fetch AND immediately before the commit — a symlink planted in between must not be written through. */
340
+ // Skill targets are known only once each module is fetched; the commit-time re-check covers them.
341
+ // skill name, lower-cased → { module, path, name }: one directory per skill, and on a
342
+ // case-insensitive filesystem `Foo` and `foo` are one directory.
343
+ const skillOwners = new Map();
338
344
  const assertHomeShape = () => {
339
345
  for (const { module } of sources) {
340
- for (const target of [join(modulesRoot, module.name), join(skillsRoot, module.name)]) {
341
- let st = null;
342
- try { st = lstatSync(target); } catch {}
343
- if (st) throw fail("E_MATERIALIZE_HOME", `${target} already exists — the instance already carries module ${module.name}; materialize never overwrites a module in place`, { home: homeAbs, module: module.name, path: target });
344
- }
346
+ const target = join(modulesRoot, module.name);
347
+ let st = null;
348
+ try { st = lstatSync(target); } catch {}
349
+ if (st) throw fail("E_MATERIALIZE_HOME", `${target} already exists — the instance already carries module ${module.name}; materialize never overwrites a module in place`, { home: homeAbs, module: module.name, path: target });
350
+ }
351
+ for (const owner of skillOwners.values()) {
352
+ const name = owner.name, target = join(skillsRoot, name);
353
+ let st = null;
354
+ try { st = lstatSync(target); } catch {}
355
+ if (st) throw fail("E_MATERIALIZE_HOME", `${target} already exists — the instance already carries a skill named ${name}; materialize never overwrites a skill in place`, { home: homeAbs, module: owner.module, skill: name, path: target });
345
356
  }
346
357
  for (const p of parents) {
347
358
  let st = null;
@@ -378,7 +389,6 @@ export async function materialize(resolution, home, options = {}) {
378
389
  mkdirSync(join(staging, "skills"), { recursive: true, mode: 0o755 });
379
390
 
380
391
  const moduleRows = [], skillRows = [], capabilityBlocks = [], recorded = {};
381
- const skillOwners = new Map();
382
392
  for (const { module, source } of sources) {
383
393
  const stagedModule = join(staging, "modules", module.name);
384
394
  const finalModule = join(modulesRoot, module.name);
@@ -412,15 +422,21 @@ export async function materialize(resolution, home, options = {}) {
412
422
  }
413
423
 
414
424
  for (const skill of moduleSkills(resolution, module, stagedModule)) {
415
- const owner = skillOwners.get(skill.name);
416
- if (owner && owner !== module.name) {
417
- throw fail("E_SKILL_DUPLICATE", `skill ${JSON.stringify(skill.name)} is contributed by both ${owner} and ${module.name}`, { name: skill.name, modules: [owner, module.name] });
425
+ // Skills land flat, so a name is one directory in the home: two sources for one
426
+ // name — two modules, or two paths of one module — are a duplicate.
427
+ const key = skill.name.toLowerCase();
428
+ const owner = skillOwners.get(key);
429
+ if (owner && owner.module === module.name && owner.path === skill.path && owner.name === skill.name) continue;
430
+ if (owner) {
431
+ const detail = owner.module === module.name ? `${module.name} (${owner.path} and ${skill.path})` : `both ${owner.module} and ${module.name}`;
432
+ const names = owner.name === skill.name ? JSON.stringify(skill.name) : `${JSON.stringify(owner.name)} / ${JSON.stringify(skill.name)} (one directory on a case-insensitive filesystem)`;
433
+ throw fail("E_SKILL_DUPLICATE", `skill ${names} is contributed by ${detail}`, { name: skill.name, modules: [owner.module, module.name] });
418
434
  }
419
- skillOwners.set(skill.name, module.name);
435
+ skillOwners.set(key, { module: module.name, path: skill.path, name: skill.name });
420
436
  const src = join(stagedModule, ...skill.path.split("/"));
421
- const staged = join(staging, "skills", module.name, skill.name);
437
+ const staged = join(staging, "skills", skill.name);
422
438
  copyTree(src, staged, `${module.name}/${skill.path}`);
423
- skillRows.push({ module: module.name, name: skill.name, from: `${MODULES_DIR}/${module.name}/${skill.path}`.split(sep).join("/"), path: join(skillsRoot, module.name, skill.name) });
439
+ skillRows.push({ module: module.name, name: skill.name, from: `${MODULES_DIR}/${module.name}/${skill.path}`.split(sep).join("/"), path: join(skillsRoot, skill.name) });
424
440
  }
425
441
 
426
442
  for (const rel of moduleInjects(resolution, module, stagedModule)) {
@@ -478,10 +494,8 @@ export async function materialize(resolution, home, options = {}) {
478
494
  };
479
495
  try {
480
496
  for (const p of [join(homeAbs, ".oats"), modulesRoot, join(homeAbs, ".agents"), skillsRoot, join(homeAbs, ".claude")]) mkdirTracked(p);
481
- for (const { module } of sources) {
482
- placeDir(join(staging, "modules", module.name), join(modulesRoot, module.name));
483
- if (existsSync(join(staging, "skills", module.name))) placeDir(join(staging, "skills", module.name), join(skillsRoot, module.name));
484
- }
497
+ for (const { module } of sources) placeDir(join(staging, "modules", module.name), join(modulesRoot, module.name));
498
+ for (const row of skillRows) placeDir(join(staging, "skills", row.name), row.path);
485
499
  swapIn(join(staging, "AGENTS.md"), join(homeAbs, "AGENTS.md"), "AGENTS.md");
486
500
  swapIn(join(staging, "instance.json"), instanceFile, "instance.json");
487
501
  // Aliases (relative symlinks), only when absent — the canonical-plus-alias construction stays; inside the transaction.
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "oats.framework": {
35
35
  "url": "https://github.com/awebai/oats.git",
36
- "ref": "oats-framework/v1.4.0",
36
+ "ref": "oats-framework/v1.4.1",
37
37
  "path": "oats-package"
38
38
  }
39
39
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -60,7 +60,7 @@ members:
60
60
  - git:github.com/acme/agents # the host is a member too
61
61
  - git:github.com/acme/platform
62
62
  packages:
63
- oats.framework: v1.4.0 # bare versions resolve through the official catalog
63
+ oats.framework: v1.4.1 # bare versions resolve through the official catalog
64
64
  oats.okf: v4.0.5
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }