@awebai/oats 0.25.4 → 0.25.6

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.
@@ -85,10 +85,11 @@ can spawn. `owns`/`reads` are responsibility/context, not ACLs. See
85
85
  [knowledge](knowledge.md) for the **prepared** version scope, provisioning and
86
86
  commands, and [migration](knowledge-migration.md) before updating v1.
87
87
 
88
- **`oats.aweb`** fills `messaging`: mints an instance identity at spawn,
89
- removes it at retire, contributes the aweb messaging and team skills, wires
90
- the channel plugin so sessions are woken by mail, and exposes
91
- `oats aweb roster` and `oats aweb setup`. Requires the `aw` CLI.
88
+ **`oats.aweb`** fills `messaging`: mints an instance identity at spawn (local
89
+ mode) or grants an instance an expiring session as a resident global identity
90
+ (global mode), removes or revokes it at retire, contributes the aweb messaging
91
+ and team skills, wires the channel plugin so sessions are woken by mail, and
92
+ exposes `oats aweb roster` and `oats aweb setup`. Requires the `aw` CLI.
92
93
 
93
94
  **`oats.jira`** fills `tasks`: the `jira-tasks` protocol and an advisory
94
95
  spawn hook. Requires `acli`; settings commonly include `site` and `project`.
@@ -135,6 +136,26 @@ remove it on `retire`; supply the roster; teach send, reply, chat, and "read
135
136
  the event first" in the inject and skill; contribute launch arguments so the
136
137
  session is woken; enforce the soul type's `reach` on both sides; state
137
138
  whether the address outlives the instance; and keep task coordination out.
139
+ Any messaging provider emits `identity: { mode, alias, team, address|null,
140
+ resident|null, grant?: { id, expiresAt, scopes } }` in its spawn meta (and
141
+ in its launch meta when it renews); `oats.aweb` is the reference
142
+ implementation. **This is a messaging-layer contract, not an oats.aweb
143
+ detail** (decision 27): from kernel 0.25.6 the kernel copies it through as
144
+ the principal the instance *acts as* — `oats status --json
145
+ instances[].identity`, the roster's `identity:` line, `oats inspect --home …
146
+ selected.identity` — preferring the capability whose captured layer is
147
+ `messaging`, adding `provider: <capability id>`, and never interpreting
148
+ `grant`. The kernel offers no `--identity` flag: the choice travels as
149
+ `--provider <cap> identity.mode=… identity.resident=…` and is bound by the
150
+ spawn decision's `effective.providers`.
151
+
152
+ A provider whose settings include a **host fact** — a custody directory, a
153
+ state root — declares that key `hostOnly: true` in its manifest. The
154
+ resolver then accepts it **only** from the deployment's `oats-local.yaml`
155
+ `settings.<cap>` and refuses it in the workspace file, `byTeam` payloads, a
156
+ soul's slot payload and `--provider` flags (`E_WORKSPACE_SCHEMA`, reason
157
+ `host-only-key`, path and key named). The provider cannot enforce this
158
+ itself: it receives one merged payload without provenance.
138
159
 
139
160
  **Tasks.** Teach claim, update, block, hand off, and complete; identify the
140
161
  instance to the tracker in a way that survives it; keep conversation out.
@@ -197,12 +218,57 @@ warning naming the fresh-purpose remedy. On an older `aw` the pre-1.36.1
197
218
  report stands (`aliasReusable: false`, warning naming aweb-abim), because
198
219
  that CLI cannot revoke the certificate.
199
220
 
200
- ## oats.aweb settings (1.10.0)
221
+ ## oats.aweb settings (1.12.0)
201
222
 
202
223
  Set in `oats-local.yaml` under `settings.oats.aweb.<key>` (host-owned), in the
203
224
  soul's `messaging:` payload (true of every instance), or per spawn with
204
- `oats spawn … --provider oats.aweb <key>=<value>`.
205
-
225
+ `oats spawn … --provider oats.aweb <key>=<value>`. The effective payload is
226
+ merged in order: workspace messaging, `byTeam[team]`, soul messaging,
227
+ `oats-local.yaml` `settings.oats.aweb`, then per-spawn `--provider` values.
228
+ `residents` is host-file-only: put custody paths only in `oats-local.yaml`,
229
+ never in a committed workspace or soul file (current kernels document this rule
230
+ but do not yet enforce provenance in the hook payload).
231
+
232
+ - `team: <team id>`. The payload team wins over `OATS_TEAM_ID`/
233
+ `OATS_TEAM_NAME`; if both are set and differ, the hook warns and uses the
234
+ payload. Workspace v2 spawns can have an empty `OATS_TEAM_ID`, so set this in
235
+ the payload for global grants.
236
+ - `identity.mode: local | global` (default `local`). Any other value is fatal.
237
+ Local mode is the historical behavior: a spawned team identity is minted for
238
+ the instance, or `identity.source` uses the existing retained-seat flow below.
239
+ Its spawn meta includes `identity: { mode: "local", alias, team, address:
240
+ null, resident: null }` beside the existing top-level `alias`, `team`, and
241
+ `delivery` keys.
242
+ - `identity.mode: global` makes the instance act as a resident global identity
243
+ through an aweb session grant; it never mints a new global identity and never
244
+ copies root keys into the instance home. `identity.resident` is required and
245
+ resolves through `residents.<name>` to an absolute custody directory whose
246
+ `.aw/identity.yaml` already exists. Missing or unresolved residents fail with
247
+ the `oats-local.yaml settings.oats.aweb.residents.<name>` key to set. Optional
248
+ `identity.scopes` defaults to exactly `[mail.read, mail.send, chat.read,
249
+ chat.send]`; optional `identity.ttl` defaults to `8h` (aw accepts `60s` to
250
+ `720h`). Spawn runs `aw id grant mint --scope <comma-list> --ttl <ttl>
251
+ --label oats:<instance> --out <home>/.aweb-identity --json` from the custody
252
+ directory with `AWEB_IDENTITY_HOME` removed from the child environment: in aw
253
+ 1.36.1, grant commands are not identity-home-aware and intentionally refuse
254
+ both `--identity-home` and external `AWEB_IDENTITY_HOME`, so cwd selects the
255
+ custody identity. The hook parses the whole JSON document because aw `--json`
256
+ output is indented across lines, with a fallback to the first brace-prefixed
257
+ block when progress lines precede it; it then verifies the minted grant's
258
+ `team_id` and returns
259
+ `env.AWEB_IDENTITY_HOME=<home>/.aweb-identity`. If the minted team differs,
260
+ the hook revokes the grant and keeps nothing. As of aw 1.36.1, receiving,
261
+ wake registration and `aw whoami` work through a grant, but sending mail or
262
+ chat through a grant is rejected by the server with 422 (`from_did must match
263
+ the authenticated sender`) because the aw client signs with the grant-key DID
264
+ where the server expects the resident's. Retire revokes
265
+ `meta.identity.grant.id` through the custody directory; with no grant id it
266
+ reports `nothing-to-revoke`. A failed revoke exits nonzero and reports the TTL
267
+ expiry.
268
+ - `residents: { <name>: /abs/custody/dir }` is the host-owned map for global
269
+ mode. Each custody directory's `.aw` holds the resident identity root keys and
270
+ team certificate. Do not put this map in committed source; the hook cannot
271
+ distinguish payload provenance.
206
272
  - `delivery: channel | session` (default `channel`). `session` hands
207
273
  notification delivery to the host wake broker: `AWEB_DELIVERY=session` in
208
274
  the launch environment (declared by the manifest), no Claude channel flag,
@@ -48,8 +48,9 @@ capabilities plus `oats.core`), so a public soul stays usable.
48
48
  Two teams that need two different messaging identities (an open-source team
49
49
  and a hosted-operations team, say) stay in ONE workspace: `team:` is a label,
50
50
  and the provider payload is addressed by label under `messaging.byTeam` (§2).
51
- Read §8b before relying on it: the kernel merges `byTeam`, but oats.aweb 1.11.2
52
- does not yet read the `team` it delivers.
51
+ Read §8b before relying on it: oats.aweb 1.12.0 mints into the `team` the
52
+ payload names, but the `.aw` root it mints FROM is still found by search and
53
+ must hold that team's membership (1.11.2 ignored `team` altogether).
53
54
 
54
55
  ## 2. Write `oats-workspace.yaml` v2 in the host repo
55
56
 
@@ -76,8 +77,8 @@ members:
76
77
  - git:github.com/acme/platform
77
78
  packages:
78
79
  oats.framework: v1.1.3
79
- oats.okf: v2.1.3
80
- oats.aweb: v1.11.2
80
+ oats.okf: v2.1.4
81
+ oats.aweb: v1.12.0
81
82
  teams:
82
83
  global: { description: Org-wide }
83
84
  engineering: { description: Platform }
@@ -136,7 +137,7 @@ they enumerate `souls/*/soul.yaml`. A soul left under `agents/` is invisible to
136
137
  | 0.24 | v2 |
137
138
  |---|---|
138
139
  | `schemaVersion: 1` | `schemaVersion: 2` |
139
- | `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.3#oats-package }` | `capabilities: { oats.okf: { from: package } }` — or nothing, if the workspace default already says so |
140
+ | `requires.knowledge: { capability: oats.okf, source: git:…@v2.1.4#oats-package }` | `capabilities: { oats.okf: { from: package } }` — or nothing, if the workspace default already says so |
140
141
  | `requires.capabilities.<cap>: { source: git:… }` | `<cap>: { from: package }` (published) or `<cap>: { from: here }` / `{ from: <repo key> }` (a member capability) |
141
142
  | `source: repo:…` / `path:` | `{ from: here }` |
142
143
  | `defaults.capabilities` | fold into `capabilities:`; use `off` to remove a workspace default |
@@ -217,8 +218,8 @@ messaging identity per team (§1), a soul that loses its label silently lands
217
218
  outside every team-addressed payload; nothing refuses it. Label the membership
218
219
  when a whole repo belongs to one team, and the soul when it does not.
219
220
 
220
- **Per-soul memory-harvest opt-out:** not available in OKF 2.1.3 — an OKF 2.1.4
221
- item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
221
+ **Per-soul memory-harvest opt-out:** not available in OKF 2.1.3 or 2.1.4 — a
222
+ later OKF item. Neither `okf.json` (`version`, `owner`, `owns`, `reads`) nor the settings
222
223
  payload (`bindings-file`, `state-dir`, `harvest-runtime`, `harvest-model`) has a
223
224
  key that keeps a soul registered for reads while excluding it from harvest. A
224
225
  soul that must not be harvested today says `knowledge: none` (no OKF at all for
@@ -327,7 +328,7 @@ Member capabilities need no approval: membership is the trust.
327
328
  **Non-interactive approval (CI, scripted rebuilds):**
328
329
 
329
330
  ```bash
330
- oats sync --approve oats.okf@v2.1.3 --approve oats.aweb@v1.11.2
331
+ oats sync --approve oats.okf@v2.1.4 --approve oats.aweb@v1.12.0
331
332
  ```
332
333
 
333
334
  `--approve <id>@<version>` is repeatable and approves **exactly** the entry the
@@ -405,11 +406,11 @@ machine-level setting would give the seat to EVERY instance of every messaging
405
406
  soul on that machine, and a seat can be held once. The Desktop's
406
407
  confirmed apply carries the same map.
407
408
 
408
- ## 8b. Where the team `.aw` lives now (oats.aweb 1.11.2), and what `byTeam` does today
409
+ ## 8b. Where the team `.aw` lives now, and what `byTeam` does today
409
410
 
410
411
  A freshly minted identity (every spawn without `identity.source`) needs an
411
412
  **initialised aweb root**: a directory holding `.aw` with a team membership to
412
- mint into. oats.aweb 1.11.2's spawn hook looks for `.aw` among these, first hit
413
+ mint into. oats.aweb's spawn hook (1.11.2 and 1.12.0 alike) looks for `.aw` among these, first hit
413
414
  wins: the declared team scope (`OATS_TEAM_SCOPE`, from the removed
414
415
  `oats-config.yaml` `team:` block — **empty under v2**), the instance home, the
415
416
  git repo containing the home, the resolution context (the soul's work repo) and
@@ -0,0 +1,42 @@
1
+ # OATS 0.25.5
2
+
3
+ Patch release: one lifecycle-contract defect fixed; two provider releases
4
+ pinned in the official catalog.
5
+
6
+ ## Fixed
7
+
8
+ - **Launch-hook `meta` was collected and discarded.** A capability's `launch`
9
+ hook may return `meta` like the spawn hook does, but the start/restart path
10
+ consumed only its `launch` arguments and `env`, so `instance.json.capabilityMeta`
11
+ kept the spawn-time value forever. A provider that re-issues a credential at
12
+ every start (e.g. a renewed messaging session grant) would have left the
13
+ ORIGINAL id on record, and retire — which reads that record as `OATS_META` —
14
+ would have revoked the wrong one while the live one ran to its TTL. After a
15
+ successful start the kernel now merges each capability's launch `meta` over
16
+ its entry; a hook answering without `meta` keeps its prior entry; a failed
17
+ launch preparation changes nothing. Regression test in
18
+ `test/session-restart.test.mjs`; contract paragraph in `docs/capabilities.md`.
19
+
20
+ No new flags, fields or hook events. `oats version --json` unchanged apart from
21
+ the version.
22
+
23
+ ## Catalog
24
+
25
+ - **oats.okf → v2.1.4** (mirror in `capabilities/oats-okf` synced, published
26
+ tag `2af47ad3`): sparse staging to `base.root` from a single-branch partial
27
+ clone with streamed object reads (large repositories and large files outside
28
+ the knowledge base no longer matter), `git-timeout` binding key (600 s for
29
+ remote operations), migration record hygiene + `oats okf migrate --forget`,
30
+ owners keyed by `OATS_SOUL_ID` with a one-time rewrite of same-name path pins
31
+ (closes the `E_OWNER`-after-member-commit regression; needs kernel ≥ 0.25.3),
32
+ and retire removes a drained source's `okf-<id>` scheduler job.
33
+ - **oats.aweb → v1.12.0** (byte-identical to `capabilities/oats-aweb`):
34
+ resident identity session grants — `identity.mode: local | global`, `team`
35
+ read from the payload (so `messaging.byTeam.<label>.team` is now the per-label
36
+ identity), host-only `residents` map, and the messaging-layer meta key
37
+ `identity` on every spawn. Known limitation: sending mail/chat through a
38
+ grant is rejected by the aweb server as of aw 1.36.1 (client bug; receive,
39
+ wake and whoami work). Guide truths in `docs/workspaces.md` and
40
+ `docs/rebuild-to-v2.md` updated from "1.11.2 ignores `team`" to what 1.12.0
41
+ does.
42
+
@@ -0,0 +1,37 @@
1
+ # OATS 0.25.6
2
+
3
+ Decision 27 — per-spawn identity choice (local | global) — lands its kernel half.
4
+ No new CLI flags; three provider-neutral additions and one manifest attribute.
5
+
6
+ ## Added
7
+
8
+ - **`decision.effective.providers`** — the spawn decision now binds the merged
9
+ per-module payloads the providers will receive (exactly the preview's
10
+ `settings.<cap>`), so a confirmed apply covers every provider fact by value.
11
+ - **`hostOnly` settings keys** — a capability manifest may mark a settings key
12
+ `hostOnly: true`; the resolver accepts it only from the deployment's
13
+ `oats-local.yaml settings.<cap>` and refuses it in the workspace file, `byTeam`
14
+ payloads, a soul's slot payload and `--provider` flags (`E_WORKSPACE_SCHEMA`,
15
+ reason `host-only-key`). Closes the documented hole where a committed file
16
+ could point a messaging provider at a custody root.
17
+ - **Served identity on the roster** — `oats status --json instances[].identity`,
18
+ the text roster's `identity:` line and `oats inspect --home … selected.identity`
19
+ carry the principal an instance acts as, copied from its messaging provider's
20
+ hook meta (`{ mode, alias, team, address, resident, grant?, provider }`).
21
+ - `oats version --json` `features[]` gains `served-identity`.
22
+
23
+ ## Desktop (10B-0)
24
+
25
+ - **Terminal owner leases** (PR #117) — the Desktop's terminal wire is now
26
+ `terminalApi: 2`: every terminal is a lease (256-bit token) held by the
27
+ admitted renderer *document*; a navigation, reload or renderer crash revokes
28
+ the document and cleans its viewers; the 20-terminal cap is reserved
29
+ synchronously so parallel opens cannot overshoot; writes are bounded and
30
+ one-way. Maintainer's native gate (real Electron, tmux, PTY) passed 23/23:
31
+ open/ready/write/close, quota, reload revocation, source isolation, no
32
+ residue after quit. See `packages/desktop/docs/terminal-owner-leases.md`.
33
+
34
+ ## For providers
35
+
36
+ oats.aweb 1.13.0 (next) can declare `residents` `hostOnly`. Any messaging provider
37
+ that emits `identity` in its spawn/launch meta appears on the roster.
@@ -29,7 +29,7 @@ two roles never collapse: `oats-okf`, `oats-aweb`, `oats-jira`, `oats-linear`,
29
29
  their `oats-package/` is consumed only as a package: `from: package`, pinned
30
30
  in the workspace's `packages:`, locked and approved per version. The framework's
31
31
  own souls therefore say `oats.okf: { from: package }` even though `oats-okf` is
32
- a member. A bare version in `packages:` (`oats.okf: v2.1.3`) resolves through
32
+ a member. A bare version in `packages:` (`oats.okf: v2.1.4`) resolves through
33
33
  the catalog; a package outside it is written `git:<repo>@<ref>`.
34
34
 
35
35
  ## Join it on your machine
@@ -61,7 +61,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
61
61
 
62
62
  packages: # the ONLY versioned things
63
63
  oats.framework: v1.1.3 # bare version → resolves through the official catalog
64
- oats.okf: v2.1.3
64
+ oats.okf: v2.1.4
65
65
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
66
66
 
67
67
  teams: # labels, declared once so they cannot drift
@@ -383,13 +383,16 @@ a label under `byTeam` that is not declared in `teams:` is `E_WORKSPACE_SCHEMA`.
383
383
  **`byTeam` is kernel-merged; whether a provider honours what arrives is the
384
384
  provider's.** `spawn --preview` shows the merged `settings.<cap>` so the
385
385
  delivery is verifiable, and `instance.json.providers.<cap>` records it — but
386
- oats.aweb **1.11.2 does not read `team` from its payload** (it resolves the
387
- team from the removed `oats-config.yaml` `team:` block, else the active team at
388
- the `.aw` root it finds), so for 1.11.2 `byTeam` is a recorded intent, not a
389
- per-label identity; the per-repo `.aw` placement in
390
- [rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-oatsaweb-1112-and-what-byteam-does-today)
391
- is the working alternative. An oats.aweb release that reads `team` from the
392
- payload closes the gap without a workspace edit (release notes will name it).
386
+ **oats.aweb 1.12.0 reads `team` from its payload** and mints into exactly that
387
+ team (`--team-id`), warning when the payload disagrees with an `OATS_TEAM_*`
388
+ value — so `byTeam.<label>.team` IS the per-label identity. What the payload
389
+ does not change is **where the `.aw` root is found**: the hook still searches
390
+ the bounded candidates in
391
+ [rebuild-to-v2.md §8b](rebuild-to-v2.md#8b-where-the-team-aw-lives-now-and-what-byteam-does-today)
392
+ and that root must hold a membership of the named team (the deployment's `.aw`
393
+ joined to every team its labels name is the simple layout). On oats.aweb
394
+ 1.11.2 `team` was ignored (the root's active team won), so `byTeam` there is
395
+ a recorded intent only.
393
396
  A store (`stores: { <name>: <repo ref> }`) names a repository; where a
394
397
  knowledge base lives inside it is the knowledge provider's own concern — for
395
398
  OKF 2.1.3 that is the **bindings file** (`bases.<alias>.repository` + `root`,
package/lib/core.mjs CHANGED
@@ -6097,7 +6097,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
6097
6097
  const inst = instance || meta?.instance || basename(home);
6098
6098
  const command = renderLaunchRecipe(recipe, { home, instance: inst });
6099
6099
  const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
6100
- return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen };
6100
+ return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
6101
6101
  }
6102
6102
  /** The environment a planned launch runs under: the host's base, the
6103
6103
  * capabilities' validated env, the configuration's literals and its
@@ -6603,7 +6603,14 @@ function* spawnBody(root, agent, o = {}) {
6603
6603
  // Workspace model: the decision binds WHAT WILL BE MATERIALIZED — the
6604
6604
  // resolution revision (member commits, package commits, payloads). A member
6605
6605
  // that moved between preview and apply changes it → E_DECISION_STALE.
6606
- if (o.prepared) d.resolution = o.prepared.resolution.revision;
6606
+ if (o.prepared) {
6607
+ d.resolution = o.prepared.resolution.revision;
6608
+ // Decision 27 (K1′): the decision also binds the merged per-module payloads the spawn will
6609
+ // hand each provider (exactly what reaches OATS_SETTINGS) — so a confirmed apply covers every
6610
+ // provider fact (an identity choice, a delivery mode) by value, not only through the
6611
+ // resolution revision. Kernel-agnostic: whatever keys the payloads carry.
6612
+ d.effective.providers = structuredClone(o.prepared.resolution.payloads ?? {});
6613
+ }
6607
6614
  d.revision = createHash("sha256").update(canonicalJson(d)).digest("hex").slice(0, 24);
6608
6615
  return d;
6609
6616
  };
@@ -7356,6 +7363,25 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
7356
7363
  }
7357
7364
  }
7358
7365
 
7366
+ /** Decision 27 (K2): the principal an instance ACTS AS, as its messaging-layer provider reported
7367
+ * it — a documented layer contract `{ mode: "local"|"global", alias, team, address|null, resident|null,
7368
+ * grant?: { id, expiresAt, scopes } }` under `identity` in the provider's hook meta. The kernel copies it
7369
+ * through and never interprets `grant`. Preference: the capability whose captured layer is "messaging";
7370
+ * else any capability that emitted an `identity` object. Null when none did. */
7371
+ export function servedIdentityOf(meta) {
7372
+ const cm = meta?.capabilityMeta;
7373
+ if (!cm || typeof cm !== "object") return null;
7374
+ const runtime = Array.isArray(meta.capabilityRuntime) ? meta.capabilityRuntime : [];
7375
+ const messaging = runtime.filter((c) => c && c.layer === "messaging").map((c) => c.id);
7376
+ const pick = (ids) => { for (const id of ids) { const v = cm[id]?.identity; if (v && typeof v === "object" && !Array.isArray(v)) return { ...v, provider: id }; } return null; };
7377
+ return pick(messaging) ?? pick(Object.keys(cm));
7378
+ }
7379
+ /** One line for a roster/inspect: `acts as <address> via grant, expires <t>` or `alias <alias> on <team>`. */
7380
+ export function servedIdentityLine(identity) {
7381
+ if (!identity) return null;
7382
+ if (identity.mode === "global" && identity.grant) return `acts as ${identity.address || identity.alias || identity.resident || "?"} via grant${identity.grant.expiresAt ? `, expires ${identity.grant.expiresAt}` : ""}`;
7383
+ return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
7384
+ }
7359
7385
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
7360
7386
  const windows = tmuxWindows(tmuxSession);
7361
7387
  const readInstancesOf = (agentDir) => {
@@ -7394,7 +7420,8 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
7394
7420
  try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
7395
7421
  catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
7396
7422
  }
7397
- return { ...meta, ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
7423
+ const identity = servedIdentityOf(meta);
7424
+ return { ...meta, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
7398
7425
 
7399
7426
  });
7400
7427
  };
@@ -8296,6 +8323,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8296
8323
  // prepares the target runtime under its CAPTURED settings.
8297
8324
  const ctx = contextDir || meta.repo;
8298
8325
  const withLaunchHook = [];
8326
+ let hookMeta;
8299
8327
  for (const p of capturedProviders(meta, frozen)) {
8300
8328
  const manifest = ctx ? capabilityManifest(p.id, ctx) : undefined;
8301
8329
  if (!manifest) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer installed in the scope; reinstall it (oats install) or respawn the instance; nothing was stopped`);
@@ -8328,6 +8356,12 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8328
8356
  refreshed.push(c.capability);
8329
8357
  }
8330
8358
  Object.assign(env, res.env || {});
8359
+ // A launch hook's `meta` is part of its documented return (the same shape
8360
+ // spawn persists as capabilityMeta). It was collected and then dropped
8361
+ // here, so a provider that re-issues a credential at every start — a
8362
+ // renewed session grant, for instance — left the ORIGINAL id on record and
8363
+ // retire undid the wrong one. Carry it to the caller; the start records it.
8364
+ hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
8331
8365
  }
8332
8366
  const unprepared = contributions.filter((c) => c.capability !== null && !refreshed.includes(c.capability) && c.launch && c.launch[frozen.runtime] !== undefined && c.launch[runtime] === undefined).map((c) => c.capability);
8333
8367
  const legacyArgs = contributions.filter((c) => c.capability === null && c.launch && Object.keys(c.launch).length);
@@ -8335,7 +8369,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
8335
8369
  if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.runtime} launch arguments at spawn and none for ${runtime}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
8336
8370
  const launch = {};
8337
8371
  for (const c of contributions) { if (c.capability === null && refreshed.length) continue; for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
8338
- return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed };
8372
+ return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed, ...(hookMeta ? { meta: hookMeta } : {}) };
8339
8373
  }
8340
8374
 
8341
8375
  /** Start a stopped instance again in its existing home: no new home, no
@@ -8639,7 +8673,7 @@ export function startInstanceSession(home, o = {}) {
8639
8673
  // The independent receipt first (retire and session consult it), then the
8640
8674
  // mutable metadata; both tmp+rename. A failure between them is what the
8641
8675
  // pending receipt exists for.
8642
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
8676
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
8643
8677
  checkRoots();
8644
8678
  const baselinePath = retirementBaselinePath(realHome);
8645
8679
  let baseline;
@@ -8654,7 +8688,10 @@ export function startInstanceSession(home, o = {}) {
8654
8688
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
8655
8689
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
8656
8690
  const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
8657
- ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
8691
+ ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
8692
+ // Launch-hook meta lands per capability over the spawn's record; a hook
8693
+ // that answered without meta keeps its previous entry (retire reads it).
8694
+ ...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
8658
8695
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
8659
8696
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
8660
8697
  if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
@@ -8753,7 +8790,7 @@ export function startInstanceSession(home, o = {}) {
8753
8790
  const resolvedCfg = resolveOatsConfig(context, meta.agent);
8754
8791
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
8755
8792
  const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
8756
- launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
8793
+ launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
8757
8794
  command = launchPlan.command; model = launchPlan.model;
8758
8795
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
8759
8796
  const resolved = resolveModelPreference(String(o.model), runtime);
@@ -8770,6 +8807,7 @@ export function startInstanceSession(home, o = {}) {
8770
8807
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
8771
8808
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
8772
8809
  const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
8810
+ ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}),
8773
8811
  ...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
8774
8812
  let target = receipt.target;
8775
8813
  let state = { present: false, state: "not-launched" };
package/lib/resolve.mjs CHANGED
@@ -121,6 +121,23 @@ function assertNoReservedKey(value, path) {
121
121
  }
122
122
  }
123
123
 
124
+ /** Settings keys a manifest marks `hostOnly: true` (decision 27, K1″). */
125
+ export function hostOnlyKeys(manifest) {
126
+ const out = new Set();
127
+ const decl = isObject(manifest?.settings) ? manifest.settings : {};
128
+ for (const [key, spec] of Object.entries(decl)) if (isObject(spec) && spec.hostOnly === true) out.add(key);
129
+ return out;
130
+ }
131
+ /** Refuse a hostOnly key in a committed or per-spawn payload layer: only oats-local.yaml `settings.<cap>` may carry it. */
132
+ function assertNoHostOnlyKey(value, path, hostOnly, capability) {
133
+ if (!hostOnly.size || !isObject(value)) return;
134
+ for (const key of hostOnly) {
135
+ if (Object.hasOwn(value, key)) {
136
+ throw fail("E_WORKSPACE_SCHEMA", `${path}/${key}: ${show(key)} is a host-only setting of ${capability} (its manifest marks it hostOnly) — it may appear only in the deployment's oats-local.yaml under settings.${capability}, never in a committed workspace or soul file or a --provider flag (decision 27)`, { path: `${path}/${key}`, key, capability, reason: "host-only-key" });
137
+ }
138
+ }
139
+ }
140
+
124
141
  /** Canonical JSON: object keys sorted (recursively), arrays in order, no whitespace. */
125
142
  export function canonicalJson(value) {
126
143
  if (value === undefined) return "null";
@@ -569,15 +586,21 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
569
586
  for (const slot of SLOTS) if (isObject(definition[slot])) assertNoReservedKey(definition[slot], `/${slot}`);
570
587
  for (const m of modules) {
571
588
  const layers = [];
589
+ // Decision 27 (K1″): a manifest may mark a settings key `hostOnly` — a host fact (a custody
590
+ // path, a state directory) that only the deployment's own oats-local.yaml may supply. Every
591
+ // committed or per-spawn layer is refused with that key present, BEFORE the merge, because the
592
+ // provider receives one merged payload without provenance and cannot enforce this itself.
593
+ const hostOnly = hostOnlyKeys(m.manifest);
594
+ const committed = (payload, path) => { assertNoHostOnlyKey(payload, path, hostOnly, m.name); layers.push(payload); };
572
595
  if (m.layer === "messaging" && isObject(workspace?.messaging)) {
573
596
  // Decision 23: base ⊕ byTeam[soul.team]; `byTeam` never reaches the provider (nor may a team's own payload nest one).
574
597
  const { byTeam, ...base } = workspace.messaging;
575
- layers.push(base);
576
- if (team !== null && isObject(byTeam) && isObject(byTeam[team])) { assertNoReservedKey(byTeam[team], `/messaging/byTeam/${team}`); layers.push(byTeam[team]); }
598
+ committed(base, "/messaging");
599
+ if (team !== null && isObject(byTeam) && isObject(byTeam[team])) { assertNoReservedKey(byTeam[team], `/messaging/byTeam/${team}`); committed(byTeam[team], `/messaging/byTeam/${team}`); }
577
600
  }
578
- if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); layers.push(definition[m.layer]); }
579
- if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); layers.push(settings[m.name]); }
580
- if (Object.hasOwn(providers, m.name) && isObject(providers[m.name])) layers.push(providers[m.name]);
601
+ if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); committed(definition[m.layer], `/${m.layer}`); }
602
+ if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); layers.push(settings[m.name]); } // the host layer: hostOnly keys are legal here
603
+ if (Object.hasOwn(providers, m.name) && isObject(providers[m.name])) committed(providers[m.name], `/spawn/providers/${m.name}`);
581
604
  payloads[m.name] = mergePayload(...layers);
582
605
  }
583
606
 
@@ -3,12 +3,12 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v2.1.3",
6
+ "ref": "v2.1.4",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.11.2",
11
+ "ref": "v1.12.0",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.25.4",
3
+ "version": "0.25.6",
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",