@awebai/oats 0.30.1 → 0.30.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 CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
28
28
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
29
29
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
30
- findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
30
+ findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
31
  } from "../lib/core.mjs";
32
32
  import {
33
33
  writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
@@ -1792,6 +1792,9 @@ async function status() {
1792
1792
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
1793
1793
  const key = i.home ?? `${a.name}/${i.instance}`;
1794
1794
  if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
1795
+ // The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
1796
+ const launchedBy = i.home ? recordedKernelBin(i.home) : null;
1797
+ if (typeof launchedBy === "string" && (verbose || launchedBy !== CLI_BIN)) console.log(` kernel: ${launchedBy}${launchedBy !== CLI_BIN ? " (not this oats)" : ""}`);
1795
1798
  const s = ws?.soul.get(key);
1796
1799
  if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
1797
1800
  const rows = ws?.drift.get(key) || [];
@@ -1803,6 +1806,31 @@ async function status() {
1803
1806
  }
1804
1807
  }
1805
1808
 
1809
+ /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1810
+ * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1811
+ * host through `--server`, whose route reads it. */
1812
+ const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "herdr-socket", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1813
+ const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1814
+ /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1815
+ * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
1816
+ * flag() reads it; a missing value is the flag's own check. */
1817
+ function spawnArgvProblem(argv) {
1818
+ const soul = argv[0];
1819
+ for (let i = 1; i < argv.length; i++) {
1820
+ const a = argv[i];
1821
+ if (a === "--provider") { i += 2; continue; }
1822
+ if (a.startsWith("--")) {
1823
+ const name = a.slice(2);
1824
+ if (SPAWN_SWITCHES.has(name)) continue;
1825
+ if (!SPAWN_VALUE_FLAGS.has(name)) return `oats spawn: unknown flag ${a}`;
1826
+ if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) i++;
1827
+ continue;
1828
+ }
1829
+ if (/^[A-Za-z0-9_.-]+=/.test(a)) return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: a capability setting is given as --provider <capability> key=value (here: --provider <capability> ${a})`;
1830
+ return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: spawn takes one soul`;
1831
+ }
1832
+ return undefined;
1833
+ }
1806
1834
  async function spawnCmd() {
1807
1835
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
1808
1836
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1831,6 +1859,7 @@ async function spawnCmd() {
1831
1859
  // The slug rule is checked here too, before any side effect (soul fetch).
1832
1860
  if (nameFlag !== undefined) { try { explicitInstanceName(String(nameFlag)); } catch (e) { bail(e.code, e.message); throw e; } }
1833
1861
  if (args.includes("--ephemeral")) bail("E_BAD_ARGS", "--ephemeral was removed by the runtime-boundary ruling — declare the agent in a capability manifest (agents:) for automatic ephemeral semantics");
1862
+ { const problem = spawnArgvProblem(args.slice(1)); if (problem) bail("E_BAD_ARGS", problem); }
1834
1863
  let root;
1835
1864
  // A spawn needs a workspace deployment (lead decision c3-1): no oats-local.yaml
1836
1865
  // in reach is E_LOCAL_MISSING, before anything else is read. The agents root is
@@ -2823,7 +2852,7 @@ function versionCmd() {
2823
2852
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2824
2853
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2825
2854
  // 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 }));
2855
+ 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
2856
  return;
2828
2857
  }
2829
2858
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3084,6 +3113,12 @@ async function serverRouteCmd() {
3084
3113
  const r = spawnSyncProc(route.argv[0], route.argv.slice(1), { stdio: "inherit" });
3085
3114
  process.exit(r.status ?? 1);
3086
3115
  }
3116
+ // A spawn's argv is checked here, before the server is contacted.
3117
+ if (cmd === "spawn") {
3118
+ const local = args.slice(1).filter((a, i, all) => a !== "--server" && all[i - 1] !== "--server");
3119
+ const problem = spawnArgvProblem(local);
3120
+ if (problem) bail("E_BAD_ARGS", problem);
3121
+ }
3087
3122
  // Everything after the command word travels, minus the routing flags; a
3088
3123
  // local --task-file is read here and travels as --task text, since the
3089
3124
  // remote cannot read this machine's files.
@@ -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
@@ -151,10 +151,27 @@ source variable must be set on the host (`E_LAUNCH_ENV_MISSING`, before
151
151
  anything is created or stopped), and only the harness's pane receives it.
152
152
  `list` and `preview` redact every environment value, literals included.
153
153
 
154
+ **The instance's `oats`.** Every launch (`oats spawn`, `session start`,
155
+ `session restart`, locally or through `--server`) writes `<home>/.oats/bin/oats`,
156
+ a link to the launching kernel's `bin/oats.mjs`, and runs the harness with
157
+ `<home>/.oats/bin` first on `PATH` and the rest of `PATH` unchanged. Plain
158
+ `oats` inside an instance is therefore the kernel that launched it, even on a
159
+ machine whose `PATH` finds another kernel first. A launch configuration's own
160
+ `PATH` (literal or `fromEnv`) comes after it. A restart by a different kernel
161
+ re-points the link to that kernel; `spawn --no-launch` writes it too. The
162
+ recipe in `instance.json` records the target as `launch.kernelBin` (no JSON
163
+ answer carries it); `oats status` prints it
164
+ (`kernel:`) under `--verbose`, or when it is not the `oats` running the status.
165
+ A launch that cannot write the link fails with `E_LAUNCH_SHIM` naming the path
166
+ and the cause: a spawn is rolled back, a start starts nothing. The recorded
167
+ `command` does not carry the `PATH`; the kernel adds it when it runs the
168
+ command. Hooks still receive `OATS_CLI_BIN`, unchanged.
169
+
154
170
  **The launch recipe.** A spawn records what a start is made of in
155
171
  `instance.json` under `launch`: the harness, the configuration and where it
156
172
  came from, the executable, args, env, model, yolo, and each capability's
157
- launch contribution with its settings and trust. One renderer turns it into
173
+ launch contribution with its settings and trust, and the kernel that launched
174
+ it (`kernelBin`, re-written by every start). One renderer turns it into
158
175
  the `command`. Configuration `args` go after the harness's own options and
159
176
  before capability arguments; every argument is single-quoted.
160
177
 
@@ -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
@@ -1146,6 +1157,9 @@ it to a temporary copy (`soulFetched: true`).
1146
1157
  repeatable, `a.b=c` nests): a malformed pair is `E_BAD_ARGS`; a capability
1147
1158
  the soul does not resolve is `E_CAPABILITY_MISSING {capability, soul,
1148
1159
  modules}`.
1160
+ - Any other positional after the soul, or a flag spawn does not read, is
1161
+ `E_BAD_ARGS` naming the argument, before anything is resolved; a bare
1162
+ `key=value` is refused with the `--provider <capability> key=value` form.
1149
1163
 
1150
1164
  ### The decision
1151
1165
 
@@ -1228,7 +1242,7 @@ Feature `spawn-name`. `--name <slug>` is the exact name, with no prefix.
1228
1242
 
1229
1243
  | Code | Details | When |
1230
1244
  |---|---|---|
1231
- | `E_USAGE`, `E_BAD_ARGS` | | no soul; bad, contradictory or removed flags |
1245
+ | `E_USAGE`, `E_BAD_ARGS` | | no soul; bad, contradictory, removed or unknown flags; an argument after the soul |
1232
1246
  | `E_LOCAL_MISSING`, `E_NO_DEPLOYMENT` | | no `oats-local.yaml`; no `agents/` root |
1233
1247
  | `E_SOUL_UNKNOWN` | `{name, members, packages}` | no such soul, or not at `--agents-root` |
1234
1248
  | `E_SOUL_AMBIGUOUS` | `{name, repos, qualified}` | several souls answer the bare name; use one of `qualified` |
@@ -1251,6 +1265,7 @@ Feature `spawn-name`. `--name <slug>` is the exact name, with no prefix.
1251
1265
  | `E_PLACEMENT_TAKEN`, `E_IDEMPOTENCY_CONFLICT` | `{instance, home}` | |
1252
1266
  | `E_SPAWN_INCOMPLETE` | `{instance, home, launched}` | |
1253
1267
  | `E_LAUNCH_*`, `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS` | | the launch selection is refused |
1268
+ | `E_LAUNCH_SHIM` | | the home's `oats` (`<home>/.oats/bin/oats`) cannot be written; the spawn is rolled back |
1254
1269
  | `E_SCHEDULE_INVALID` | | a bad wake (`--wake-json`, `--wake-file`, `--wake-*`) |
1255
1270
  | `E_SPAWN_FAILED` | | anything else |
1256
1271
 
@@ -1288,8 +1303,9 @@ the keyed-spawn fields `decision`, `spawnIdempotencyKey`, `spawnCompleted` and
1288
1303
  `wake`; later starts add `restarts` and `restartCount`.
1289
1304
 
1290
1305
  - `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>/`.
1306
+ the copy at `<home>/.oats/modules/<cap>/`. Module skills are copied flat to
1307
+ `<home>/.agents/skills/<skill>/` (homes spawned by 0.30.1 or earlier keep
1308
+ `<home>/.agents/skills/<cap>/<skill>/`).
1293
1309
  - `providers.<cap>`: the merged payload (`{}` when none).
1294
1310
  - `workspace`: `{key, name, deployment, commit, resolution, standalone, soul,
1295
1311
  layers}`. `name` is recorded, and every hook, command and operation of the
@@ -1658,8 +1674,9 @@ selection flags. See [the start workflow](desktop-instance-start.md).
1658
1674
  - A lost response does not mean the launch failed: check status before a
1659
1675
  retry. A remote home's saved route names its execution host.
1660
1676
  - Errors: `E_BAD_ARGS`, `E_SESSION_UNKNOWN`, `E_UNSUPPORTED_MODE`,
1661
- `E_SESSION_START_BUSY`, `E_INSTANCE_RETIRING`, `E_LAUNCH_*`,
1662
- `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS`, `E_SESSION_FAILED`.
1677
+ `E_SESSION_START_BUSY`, `E_INSTANCE_RETIRING`, `E_LAUNCH_*` (among them
1678
+ `E_LAUNCH_SHIM`: the home's `oats` link cannot be written, nothing was
1679
+ started), `E_MODEL_UNKNOWN`, `E_UNSUPPORTED_HARNESS`, `E_SESSION_FAILED`.
1663
1680
 
1664
1681
  ### Upload
1665
1682
 
@@ -59,9 +59,15 @@ id distinguishes a replacement occupant of the same pane.
59
59
  (default `~/.config/...`) and starts `herdr --session oats server` if no
60
60
  server is running there.
61
61
  - The adapter speaks the Herdr socket API at an explicit protocol version,
62
- with no negotiation. New sessions use protocol 20. A recorded session target
63
- may carry protocol 20 or 22, and each call checks that the server's snapshot
64
- reports the recorded protocol.
62
+ 20 or 22. A new session records the protocol its server reports (20 for
63
+ Herdr 0.8, 22 for 0.9) and refuses any other; before 0.30.3 it always
64
+ selected 20, so Herdr 0.9 could not be spawned on. A recorded target is
65
+ never renegotiated: each call, the kernel's and the Desktop's, checks that
66
+ the server's snapshot reports the recorded protocol.
67
+ - Herdr cannot give a pane a command at creation, so OATS types the launch
68
+ command into the pane's shell. It waits until the shell has drawn its prompt
69
+ (`herdr pane read`, at most 10 s): text typed earlier is cut at the
70
+ terminal's line-buffer limit.
65
71
 
66
72
  ## Lifecycle
67
73
 
@@ -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.
@@ -0,0 +1,75 @@
1
+ # OATS 0.30.3
2
+
3
+ ## Changed
4
+
5
+ - **Desktop: the whole app works from the keyboard, with a Linux-sane keymap.**
6
+ Changed Linux and Windows defaults: the command palette is now Ctrl+Shift+P
7
+ on Linux and Windows; close tab Ctrl+Shift+W; split Ctrl+Shift+E /
8
+ Ctrl+Shift+O (close the split Ctrl+Shift+Alt+W); Spawn instance
9
+ Ctrl+Shift+N; the theme cycle has no default chord (on macOS too; the palette
10
+ keeps it). No default chord takes a key a program in the terminal reads:
11
+ plain Ctrl+W, Ctrl+K, Ctrl+\, Ctrl+P and Ctrl+B, F6, Alt+digit and
12
+ Ctrl+PgUp/PgDn (and ⌃digits on macOS) always reach the program, and nothing
13
+ binds Super. New: go to tab (⌥⌘1–⌥⌘9 / Alt+1–Alt+9), Ctrl+PgDn / Ctrl+PgUp,
14
+ ⌘3 / Ctrl+3 for Automations, and F6 / Shift+F6 to move between the sidebar,
15
+ the instances, the main area and the instance panel; from a terminal,
16
+ ⇧⌘F6 / Ctrl+Shift+F6 leaves it for the next region. Your own rebinds are
17
+ kept; one that now clashes with another shortcut is flagged in the shortcuts
18
+ editor, never changed for you. Quick Open (⌘P / Ctrl+P)
19
+ now opens the spawn dialog for the soul you pick, and Esc takes you back to
20
+ where you were; ⌘↵ / Ctrl+Enter spawns from any field, and Tab walks the
21
+ form in the order you see it. Keyboard gaps found in an audit are closed:
22
+ roster rows of unknown state, Independent nodes in the Active overview, the
23
+ Automations row menu, the schedule form and focus lost after switching tabs
24
+ or views. The keymap and the audit are in
25
+ `packages/desktop/docs/desktop-keyboard.md`.
26
+ - **Desktop: the spawn dialog says why each capability is there.** In "What
27
+ will be created", each Capabilities row shows a muted reason tag beside its
28
+ source: **Soul**, **Workspace default**, or, for a workspace default that
29
+ fills a core slot, **Workspace default · messaging** (knowledge, messaging or
30
+ tasks). It is the preview's `composedFrom`, read only from a CLI that reports
31
+ `preview-composed-from`; with an older CLI the rows show no tag, never a
32
+ guess. The Core capabilities table is unchanged.
33
+ - **Desktop: clearer overview lines, a centred rail, pinned Workspace controls.**
34
+ The Active overview draws parent links as smooth curves from a parent's
35
+ bottom to its child's top, with dashed arcs between siblings, in a colour
36
+ that is visible in every theme. The collapsed instance panel's icons sit
37
+ on its centre line. In Workspace, the Capabilities section pills and search
38
+ and the Teams header stay in view while the content scrolls, and scrolling
39
+ to the end of a list no longer moves the whole window.
40
+
41
+ ## Fixes
42
+
43
+ - **Plain `oats` inside an instance is the kernel that launched it.** On a
44
+ machine with two kernels side by side (a global 0.24 for classic
45
+ deployments and a 0.30 prefix install), an agent typing `oats` got whatever
46
+ `PATH` found first: `oats aweb teams` answered `E_UNKNOWN_COMMAND` in a 0.30
47
+ home. Every launch (spawn, `session start`, `session restart`) now writes
48
+ `<home>/.oats/bin/oats`, a link to the launching kernel, and runs the harness
49
+ with `<home>/.oats/bin` first on `PATH`. A restart by another kernel
50
+ re-points it. The recipe records the target as `launch.kernelBin`, and
51
+ `oats status` shows it (`kernel:`) when it is not the running `oats`. A
52
+ launch that cannot write the link fails with `E_LAUNCH_SHIM`. Existing homes
53
+ get the link at their next start or restart.
54
+ - **`oats spawn` refuses an argument it does not read.** A positional after
55
+ the soul, or a flag spawn does not know, is `E_BAD_ARGS` naming it, before
56
+ anything is resolved or created. `oats spawn <soul> join=oats` used to be
57
+ accepted and the bare `join=oats` ignored, so the instance joined nothing;
58
+ the refusal now names the form that was meant, `--provider <capability>
59
+ key=value`. A routed spawn (`--server`) is refused on this machine, before
60
+ the server is contacted.
61
+ - **Herdr 0.9 spawns work.** A new Herdr session was always opened at protocol
62
+ 20, so `oats spawn --backend herdr` against Herdr 0.9, which speaks protocol
63
+ 22, failed with "Herdr snapshot does not match selected protocol 20". A new
64
+ session now records the protocol its server reports when it is one OATS
65
+ supports (20 or 22), and refuses any other by name. A recorded session is
66
+ still checked against its recorded protocol on every call and is never
67
+ renegotiated. The Desktop attaches to protocol-22 sessions too; it accepted
68
+ only protocol 20 before. An instance recorded at protocol 20 is unreachable
69
+ after an in-place Herdr 0.8 → 0.9 upgrade until it is respawned.
70
+ - **A Herdr launch no longer arrives cut.** OATS typed the launch command into
71
+ the new pane before its shell had started. The terminal's line buffer
72
+ (1024 bytes on macOS) dropped the rest of the longer command, so the harness
73
+ never started: the shell waited on an unclosed quote. On Herdr 0.9, 0 of 5
74
+ fresh panes ran a 2000-character command intact. A launch now waits, up to
75
+ 10 s, until the pane has drawn its prompt, then types; 5 of 5 ran intact.
@@ -29,6 +29,7 @@ that model.
29
29
  | Instance operating doc | `<home>/AGENTS.md` (generated) |
30
30
  | Instance skills | `<home>/.agents/skills/` |
31
31
  | Instance modules | `<home>/.oats/modules/<capability>/` (the copies this instance runs) |
32
+ | Instance `oats` | `<home>/.oats/bin/oats` (a link to the kernel that last launched it) |
32
33
  | Instance record | `<home>/instance.json` (`modules`, `providers`, `workspace`, `teams`) |
33
34
 
34
35
  ## Soul anatomy
@@ -122,10 +123,11 @@ full copy** of every capability the soul resolved to:
122
123
  <agents-root>/<soul>/instances/<instance>/
123
124
  AGENTS.md # generated: soul AGENTS.md + kernel/work-mode blocks + each module's inject
124
125
  CLAUDE.md → AGENTS.md
125
- .agents/skills/ # canonical skill tree — soul skills + <capability>/<skill>/ full copies
126
- <capability>/<skill>/SKILL.md
126
+ .agents/skills/ # canonical skill tree, flat: the soul's skills and every module's, full copies
127
+ <skill>/SKILL.md # one level deep, where every harness discovers skills
127
128
  .claude/skills → ../.agents/skills
128
129
  .oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
130
+ .oats/bin/oats → <kernel>/bin/oats.mjs # the kernel that last launched this home: first on the harness's PATH
129
131
  work/ # worktree, checkout symlink, attached tree, or private directory
130
132
  TASK.md # briefing and task
131
133
  instance.json # provenance (below); `soulDir` = the soul directory hooks get as OATS_SOUL
@@ -193,7 +195,8 @@ bump affects only new spawns.
193
195
  OATS is a skill contributor, not a skill sandbox. The harness (pi, Claude Code,
194
196
  Codex) starts with cwd = the instance home and its **own** skill discovery
195
197
  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
198
+ and the copied capability skills, all flat at `.agents/skills/<skill>/SKILL.md`,
199
+ the one level deep Claude Code discovers through `.claude/skills`), the repo's own `.agents/skills/` once it
197
200
  works in `work/`, and whatever the operator keeps at machine level. All three
198
201
  are intended. Two *composed* skills with one name is a spawn error naming both
199
202
  capabilities (`E_SKILL_DUPLICATE`); a composed skill versus an ambient one is
@@ -243,7 +246,10 @@ it would bind, `providers` (the `--provider` map exactly as given) and
243
246
  the apply refuses with `E_DECISION_STALE` if a member
244
247
  moved in between. `--provider <cap> key=value` (repeatable; dotted keys nest)
245
248
  must name a capability the soul resolves (`E_CAPABILITY_MISSING` otherwise) and
246
- needs a workspace deployment. The full DTOs are in
249
+ needs a workspace deployment. Spawn takes one soul and the flags it reads:
250
+ another positional, or a flag it does not know, is `E_BAD_ARGS` naming the
251
+ argument, before anything is created (a bare `key=value` is a provider
252
+ setting given without `--provider <capability>`). The full DTOs are in
247
253
  [desktop-cli-api.md](desktop-cli-api.md#workspace-model-workspaceapi-2).
248
254
 
249
255
  Examples of spawn hooks:
@@ -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
@@ -1537,7 +1537,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1537
1537
  // Hooks also run through direct core callers (not only bin/oats).
1538
1538
  // Author this from the running kernel, never PATH, ambient env or
1539
1539
  // a caller's extraEnv: those may point at a different executable.
1540
- OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
1540
+ OATS_CLI_BIN: kernelBin(),
1541
1541
  OATS_SETTINGS: JSON.stringify(cap.settings || {}),
1542
1542
  // Where each leaf of OATS_SETTINGS came from (JSON pointer → { kind, at }; kind is
1543
1543
  // manifest-default | workspace | soul | host | spawn | anchor), so a provider can tell a
@@ -2191,6 +2191,53 @@ export function launchEnvRefs(recipe, env = process.env) {
2191
2191
  export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
2192
2192
  export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
2193
2193
 
2194
+ /** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
2195
+ export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
2196
+ /** Where a home's `oats` lives: first on its harness's PATH, so plain `oats` inside an instance is
2197
+ * the kernel that launched it, whatever other `oats` the host's PATH finds first. */
2198
+ export const kernelShimDir = (home) => join(home, ".oats", "bin");
2199
+ /** Write <home>/.oats/bin/oats, a symlink to this kernel's CLI, replacing any shim a previous
2200
+ * launch (perhaps by another kernel) left: built aside, then renamed over. `.oats` and `.oats/bin`
2201
+ * must be directories of the home itself: a link there would carry the write outside the home.
2202
+ * The target, or E_LAUNCH_SHIM naming the path and the cause (a launch never runs with the
2203
+ * wrong `oats`), before anything outside the home is touched. */
2204
+ export function writeKernelShim(home) {
2205
+ const dir = kernelShimDir(home), shim = join(dir, "oats");
2206
+ let target, aside;
2207
+ try {
2208
+ target = kernelBin();
2209
+ for (const d of [join(home, ".oats"), dir]) {
2210
+ let st;
2211
+ try { st = lstatSync(d); }
2212
+ catch (e) { if (e.code !== "ENOENT") throw e; mkdirSync(d); st = lstatSync(d); }
2213
+ if (!st.isDirectory()) throw new Error(`${d} is ${st.isSymbolicLink() ? "a symbolic link" : "not a directory"}`);
2214
+ }
2215
+ if (realpathSync(dir) !== join(realpathSync(home), ".oats", "bin")) throw new Error(`${dir} resolves outside the home`);
2216
+ aside = join(dir, `.oats-${randomUUID()}`);
2217
+ symlinkSync(target, aside);
2218
+ renameSync(aside, shim);
2219
+ return target;
2220
+ } catch (e) {
2221
+ if (aside) rmSync(aside, { force: true });
2222
+ throw oatsError("E_LAUNCH_SHIM", `cannot write the kernel shim ${shim}${target ? ` (to ${target})` : ""}: ${e.message}; nothing was started`);
2223
+ }
2224
+ }
2225
+ /** A parsed launch command's environment prefix with the home's shim directory first on PATH: a
2226
+ * configuration's PATH (literal or reference) follows it, else the PATH the command runs under. */
2227
+ function shimPathPrefix(tokens, binary, home) {
2228
+ const dir = shq(kernelShimDir(home));
2229
+ const prefix = tokens.slice(0, binary);
2230
+ const texts = prefix.map((t) => t.name !== "PATH" ? t.text : t.kind === "env" ? `PATH=${dir}:${shq(t.value)}` : `PATH=${dir}:"$${t.source}"`);
2231
+ if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
2232
+ return texts;
2233
+ }
2234
+ /** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
2235
+ * first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
2236
+ export function launchShellCommand(command, home) {
2237
+ const { tokens, binary } = parseLaunchCommand(command);
2238
+ return [...shimPathPrefix(tokens, binary, home), ...tokens.slice(binary).map((t) => t.text)].join(" ");
2239
+ }
2240
+
2194
2241
  /** The harness command line of a recipe. With no configuration the bytes
2195
2242
  * equal what spawn rendered before recipes: env prefix, the executable, the
2196
2243
  * harness's own arguments, capability launch args, the task prompt. A
@@ -2234,14 +2281,15 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
2234
2281
 
2235
2282
  /** Execution, not preview: mark pending before dispatch, then resolve native
2236
2283
  * storage inside the backend shell under the actual command environment.
2237
- * The original executable/argv is exec'd unchanged after recording succeeds. */
2284
+ * The original executable/argv is exec'd unchanged after recording succeeds,
2285
+ * with the home's kernel shim first on PATH (every launch runs through here). */
2238
2286
  function nativeRecordCommand(command, home, harness) {
2239
2287
  const { tokens, binary } = parseLaunchCommand(command);
2240
2288
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2241
2289
  const id = prepareNativeStart(home, harness);
2242
2290
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2243
2291
  const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
2244
- return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
2292
+ return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
2245
2293
  }
2246
2294
 
2247
2295
 
@@ -2374,7 +2422,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2374
2422
  version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
2375
2423
  executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
2376
2424
  args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
2377
- hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
2425
+ hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
2378
2426
  ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
2379
2427
  };
2380
2428
  const inst = instance || meta?.instance || basename(home);
@@ -2412,11 +2460,18 @@ export function redactLaunchCommand(command) {
2412
2460
  try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
2413
2461
  catch { return "<unparseable launch command withheld>"; }
2414
2462
  }
2415
- /** The recipe with every environment value withheld. */
2463
+ /** The recipe as answers carry it: every environment value withheld, and without `kernelBin`
2464
+ * (the home's record keeps it; human `oats status` reads it from there). */
2416
2465
  export function redactLaunchRecipe(recipe) {
2417
2466
  const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
2418
2467
  const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
2419
- return { ...recipe, env, ...(hooks ? { hooks } : {}) };
2468
+ const { kernelBin: _recorded, ...answered } = recipe;
2469
+ return { ...answered, env, ...(hooks ? { hooks } : {}) };
2470
+ }
2471
+ /** The kernel a home's last launch pointed its `oats` at (instance.json launch.kernelBin), or null. */
2472
+ export function recordedKernelBin(home) {
2473
+ try { const bin = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.launch?.kernelBin; return typeof bin === "string" ? bin : null; }
2474
+ catch { return null; }
2420
2475
  }
2421
2476
  /** Spawn. With `o.prepared` (workspace model: a resolution prepared by
2422
2477
  * instance-resolution.mjs) this is ASYNC — capabilities are fetched and copied
@@ -2992,7 +3047,7 @@ function* spawnBody(root, agent, o = {}) {
2992
3047
  return e;
2993
3048
  };
2994
3049
  // Workspace model: copy every resolved capability WHOLE into the new home
2995
- // (.oats/modules/<cap>/ + .agents/skills/<cap>/) and record modules/providers
3050
+ // (.oats/modules/<cap>/, its skills flat in .agents/skills/<skill>/) and record modules/providers
2996
3051
  // in instance.json. Then REBUILD the capability rows against the copies that
2997
3052
  // landed (H1): until now `resolvedCfg.capabilities` were PLANNED rows (no
2998
3053
  // skills, no inject, no dir) — hooks, environment, requirements, retirement
@@ -3034,9 +3089,14 @@ function* spawnBody(root, agent, o = {}) {
3034
3089
  const row = rows.find((c) => c.id === r.module);
3035
3090
  if (r.type === "injection") { r.path = row?.inject; continue; }
3036
3091
  if (r.type === "skill-tree") {
3037
- const skillsRoot = join(home, ".agents", "skills", r.module);
3092
+ // Module skills land flat in the canonical skills root; the entries are
3093
+ // the names materialize placed for this module (what the resolution
3094
+ // promised), verified against the root in the completeness check.
3095
+ const skillsRoot = join(home, ".agents", "skills");
3038
3096
  r.path = existsSync(skillsRoot) ? skillsRoot : undefined;
3039
- r.entries = (row?.skills || []).map((p) => basename(p));
3097
+ r.entries = Array.isArray(materializeOutcome?.skills)
3098
+ ? materializeOutcome.skills.filter((s) => s.module === r.module).map((s) => s.name)
3099
+ : (row?.skills || []).map((p) => basename(p));
3040
3100
  }
3041
3101
  }
3042
3102
  } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
@@ -3074,30 +3134,43 @@ function* spawnBody(root, agent, o = {}) {
3074
3134
  }
3075
3135
  if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
3076
3136
 
3077
- // Skills: module skills were copied WHOLE into <home>/.agents/skills/<module>/
3137
+ // Skills: module skills were copied WHOLE and FLAT into <home>/.agents/skills/<skill>/
3078
3138
  // 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
3139
+ // directory each, at the same level — the one level every harness discovers. There
3140
+ // 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
3141
  // discovery — the machine's and the repo's skills are the harness's business.
3082
3142
  const sources = [];
3083
3143
  const soulSkills = join(soulDir, "skills");
3084
3144
  if (existsSync(soulSkills)) sources.push({ id: "soul", path: soulSkills });
3085
3145
  const chosen = new Map();
3146
+ // Names compare case-insensitively: one directory per skill on a case-insensitive
3147
+ // filesystem (APFS, NTFS) must not silently merge `Foo` into `foo`.
3148
+ const moduleSkillOwner = new Map((materializeOutcome?.skills || []).map((s) => [s.name.toLowerCase(), s.module]));
3149
+ const chosenKeys = new Map();
3086
3150
  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`}`);
3151
+ const key = name.toLowerCase();
3152
+ if (chosenKeys.has(key) || moduleSkillOwner.has(key) || existsSync(join(home, ".agents", "skills", name))) {
3153
+ const other = chosenKeys.get(key) ?? (moduleSkillOwner.has(key) ? `module ${moduleSkillOwner.get(key)}'s skill` : `the existing .agents/skills/${name}`);
3154
+ throw oatsError("E_SKILL_DUPLICATE", `skill "${name}" from ${source} collides with ${other}`);
3155
+ }
3088
3156
  chosen.set(name, { src, source });
3157
+ chosenKeys.set(key, source);
3089
3158
  };
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"));
3159
+ // A refusal here comes after materialize populated the home: remove it whole,
3160
+ // like every other failure between materialize and the completeness check.
3161
+ try {
3162
+ // Same enumerator preflight used, so "what a tree promises" and "what gets
3163
+ // copied" cannot drift apart.
3164
+ for (const source of sources) for (const entry of skillEntriesIn(source.path)) offer(entry.name, entry.src, source.id);
3165
+ mkdirSync(join(home, ".agents", "skills"), { recursive: true });
3166
+ mkdirSync(join(home, ".claude"), { recursive: true });
3167
+ for (const [name, selected] of [...chosen].sort(([a], [b]) => a.localeCompare(b))) {
3168
+ // Pi's recursive skill scanner does not descend through directory symlinks.
3169
+ // Copy each selected tree so the exact instance-local set is real and immutable.
3170
+ copyTreeSafe(realpathSync(selected.src), join(home, ".agents", "skills", name));
3171
+ }
3172
+ if (!existsSync(join(home, ".claude", "skills"))) symlinkSync(join("..", ".agents", "skills"), join(home, ".claude", "skills"));
3173
+ } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3101
3174
 
3102
3175
  // EXPECTED == MATERIALIZED. Preflight proved every declared resource resolves;
3103
3176
  // this proves the copies actually landed, so "the composition is complete" is
@@ -3119,18 +3192,14 @@ function* spawnBody(root, agent, o = {}) {
3119
3192
  if (!chosen.has(name)) incomplete.push(`skill "${name}", promised by ${r.source} (${r.declared}), is missing from the composed set`);
3120
3193
  }
3121
3194
  }
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).
3195
+ // Prepared spawn: every module's declared skill must have been copied to
3196
+ // .agents/skills/<skill>/ by materialize — verify the copies landed as
3197
+ // readable skills, one level deep, where the harnesses discover them.
3125
3198
  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
3199
  for (const r of expectedResources) {
3131
3200
  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}`);
3201
+ if (!r.path) { incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but .agents/skills is absent`); continue; }
3202
+ 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
3203
  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
3204
  }
3136
3205
  }
@@ -3463,6 +3532,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3463
3532
  // instance.json beside its rendering, which is executed in the instance's
3464
3533
  // tmux window. Capabilities contributed harness-specific arguments and
3465
3534
  // environment through their spawn hook; both are recorded with provenance.
3535
+ // The home's `oats` is this kernel, launched now or later (--no-launch).
3536
+ const shimTarget = writeKernelShim(home);
3466
3537
  const recipe = {
3467
3538
  version: LAUNCH_RECIPE_VERSION, harness,
3468
3539
  launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
@@ -3470,12 +3541,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3470
3541
  args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
3471
3542
  model: model || null, ...(yolo !== undefined ? { yolo } : {}),
3472
3543
  hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
3473
- prompt: LAUNCH_PROMPT,
3544
+ prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
3474
3545
  };
3475
3546
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3476
3547
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
3477
3548
 
3478
- // Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
3549
+ // Module skills as materialize landed them (flat, .agents/skills/<skill>/),
3479
3550
  // beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
3480
3551
  // `from` = the home's module copy the skill was copied from.
3481
3552
  const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
@@ -4879,6 +4950,9 @@ export function startInstanceSession(home, o = {}) {
4879
4950
  try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
4880
4951
  if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
4881
4952
  }
4953
+ // The kernel that launches the harness is the one the agent's plain `oats` runs.
4954
+ checkRoots();
4955
+ writeKernelShim(realHome);
4882
4956
  const startedAt = new Date().toISOString();
4883
4957
  const id = randomUUID();
4884
4958
  checkRoots();
package/lib/herdr.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  /** Herdr 0.8.x/0.9.x host-local backend (explicit protocols 20/22).
2
- * SSH belongs to the CLI router; protocol selection is never negotiation. */
2
+ * SSH belongs to the CLI router. A new session records the server's protocol once; a recorded
3
+ * target is never renegotiated. */
3
4
  import { execFileSync, spawn } from "node:child_process";
4
5
  import { existsSync, mkdirSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
7
  import { dirname, join, resolve } from "node:path";
7
8
 
8
- // Keep the existing legacy/default protocol literal; captured callers select one.
9
+ // The legacy protocol literal; a new session records the protocol its server reports (ensureHerdr).
9
10
  export const HERDR_PROTOCOL = 20;
10
11
  export const HERDR_SUPPORTED_PROTOCOLS = Object.freeze([20, 22]);
11
12
  export const isSupportedHerdrProtocol = (value) => HERDR_SUPPORTED_PROTOCOLS.includes(value);
@@ -21,10 +22,15 @@ export function herdrSocket(session = "oats") {
21
22
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "herdr", "sessions", session, "herdr.sock");
22
23
  }
23
24
 
24
- export function herdrCommand(target, args, { timeout = 10000, exec = execFileSync } = {}) {
25
+ /** One Herdr CLI call on the target's socket, its stdout as text. */
26
+ function herdrOutput(target, args, { timeout = 10000, exec = execFileSync } = {}) {
25
27
  const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
26
28
  delete env.HERDR_SESSION;
27
- const output = exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
29
+ return exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
30
+ }
31
+
32
+ export function herdrCommand(target, args, io) {
33
+ const output = herdrOutput(target, args, io);
28
34
  // Some successful terminal mutations have no output; reads below require
29
35
  // their documented result shape independently.
30
36
  if (!output.trim()) return {};
@@ -42,9 +48,20 @@ export function herdrSnapshot(target, io) {
42
48
  return snapshot;
43
49
  }
44
50
 
45
- export function ensureHerdr({ binary = "herdr", socket, session = "oats" } = {}) {
46
- const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)), protocol: HERDR_PROTOCOL };
47
- try { herdrSnapshot(target); return target; }
51
+ /** The target for a NEW session on the server at `socket`: it records the supported protocol that server
52
+ * reports (Herdr 0.8 speaks 20, 0.9 speaks 22). Every later call checks the snapshot against that recorded
53
+ * protocol, so a recorded target is never renegotiated. */
54
+ function newSessionTarget(target, io) {
55
+ const protocol = herdrCommand(target, ["api", "snapshot"], io).snapshot?.protocol;
56
+ if (!isSupportedHerdrProtocol(protocol)) throw Object.assign(new Error(`Herdr server speaks protocol ${protocol ?? "unknown"}; OATS supports ${HERDR_SUPPORTED_PROTOCOLS.join(" and ")}`), { unsupportedProtocol: true });
57
+ const selected = { ...target, protocol };
58
+ herdrSnapshot(selected, io);
59
+ return selected;
60
+ }
61
+
62
+ export function ensureHerdr({ binary = "herdr", socket, session = "oats", io } = {}) {
63
+ const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)) };
64
+ try { return newSessionTarget(target, io); }
48
65
  catch (e) {
49
66
  // An explicit socket belongs to an operator-managed server. Never start a
50
67
  // different server when it cannot be inspected or is incompatible.
@@ -59,7 +76,8 @@ export function ensureHerdr({ binary = "herdr", socket, session = "oats" } = {})
59
76
  let error;
60
77
  for (let i = 0; i < 30; i++) {
61
78
  pause(100);
62
- try { herdrSnapshot(target); return target; } catch (e) { error = e; }
79
+ // A server that answers with an unsupported protocol is up: waiting longer cannot change its answer.
80
+ try { return newSessionTarget(target, io); } catch (e) { if (e.unsupportedProtocol) throw e; error = e; }
63
81
  }
64
82
  throw new Error(`Herdr did not start: ${error?.message || "no socket"}`);
65
83
  }
@@ -81,8 +99,27 @@ export function inspectHerdr(target, io) {
81
99
  return { present: !!pane, pane, agent, status: agent?.agent_status || "unknown" };
82
100
  }
83
101
 
102
+ /** Wait until a new pane's shell has drawn something (its prompt) before typing into it. Text typed
103
+ * earlier sits in the terminal's canonical line buffer, which drops a line past 1024 bytes on macOS:
104
+ * a launch command is longer than that, and arrived cut. Bounded; a Herdr that cannot read a pane
105
+ * leaves the old behaviour (type at once). */
106
+ function awaitShellReady(target, io) {
107
+ const deadline = Date.now() + (io?.shellReadyMs ?? 10000);
108
+ for (;;) {
109
+ let text;
110
+ // `pane read` answers the screen as plain text, not a JSON envelope.
111
+ const remaining = Math.max(100, deadline - Date.now());
112
+ try { text = herdrOutput(target, ["pane", "read", target.paneId, "--source", "visible"], { ...io, timeout: Math.min(2000, remaining) }); }
113
+ catch { return; }
114
+ if (typeof text === "string" && text.trim()) return;
115
+ if (Date.now() >= deadline) return;
116
+ pause(100);
117
+ }
118
+ }
119
+
84
120
  export function launchHerdr(target, command, io) {
85
121
  if (!inspectHerdr(target, io).present) throw new Error("Herdr launch pane disappeared");
122
+ awaitShellReady(target, io);
86
123
  // This is the allocated shell's one launch command. Exec avoids a fallback
87
124
  // shell that could accidentally consume a subsequent agent notification.
88
125
  herdrCommand(target, ["pane", "run", target.paneId, `exec /bin/sh -c ${quote(command)}`], io);
@@ -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.3",
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 } }