@awebai/oats 0.36.1 → 0.37.0

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.
@@ -341,7 +341,9 @@ Hooks receive:
341
341
  A spawn hook also gets `OATS_TASK`, `OATS_REPO`, `OATS_BRANCH`, `OATS_WORK`,
342
342
  `OATS_HARNESS`, `OATS_KIND` and, for a spawn a trigger started,
343
343
  `OATS_TRIGGER_EVENT_FILE`. A launch hook also gets `OATS_HARNESS` and
344
- `OATS_PREVIOUS_HARNESS`.
344
+ `OATS_PREVIOUS_HARNESS`, and `OATS_LAUNCH_PREVIEW=1` when it runs for a
345
+ preview (only a preview-aware hook does, below); on a real run
346
+ `OATS_LAUNCH_PREVIEW` is not set.
345
347
 
346
348
  `OATS_SETTINGS_ORIGINS` says where each leaf of
347
349
  `OATS_SETTINGS` came from: a JSON object from a JSON pointer to `{ kind, at }`,
@@ -350,7 +352,8 @@ A spawn hook also gets `OATS_TASK`, `OATS_REPO`, `OATS_BRANCH`, `OATS_WORK`,
350
352
  `{"/harvest":{"kind":"soul","at":"soul.yaml#/knowledge"}}`. A provider tells a
351
353
  soul-set value from a host-set one there, and never reads `soul.yaml` for it;
352
354
  a home with none recorded gives `{}`. A final JSON line may return `meta`,
353
- `brief`, `warning`, or harness-specific `launch` arguments. A **spawn or
355
+ `brief`, `warning`, or harness-specific `launch` arguments; a preview-aware
356
+ launch hook's preview answer may add `volatileEnv` (below). A **spawn or
354
357
  launch hook** may also return an `env` object for the launched process;
355
358
  returning `env` from a retire hook is an explicit contract error.
356
359
 
@@ -364,6 +367,54 @@ start (a renewed session grant, for example) leaves the CURRENT one on record. A
364
367
  launch hook that answers without `meta` keeps its previous entry; a start whose
365
368
  preparation fails changes nothing.
366
369
 
370
+ A launch hook may do idempotent provider registration on a real start (an
371
+ aweb home registering with the host wake broker, for example). How the
372
+ kernel runs it depends on whether its capability declares **preview
373
+ awareness**: `"launchPreview": true` at the top level of its manifest. The
374
+ kernel reads the declaration from the home's own module copy, so a home keeps
375
+ the behaviour of the module it was spawned with.
376
+
377
+ **A preview-aware hook** must change nothing under `OATS_LAUNCH_PREVIEW=1`,
378
+ and must return the same contribution (`launch` arguments and `env`) as for
379
+ a real start. It runs twice per start:
380
+
381
+ 1. **As a preview, under `OATS_LAUNCH_PREVIEW=1`.** The start's preflight uses
382
+ this contribution: trust, environment ownership, the harness-package
383
+ probe and the rendered command. `oats launch-config preview` (which
384
+ Desktop's start dialog uses) runs only this pass.
385
+ 2. **For real, without the flag.** This pass runs only once preflight has
386
+ passed, including the check that the home is not already running
387
+ (`E_SESSION_RUNNING`), and before a restart stops the running harness.
388
+
389
+ The real run's `meta` and warnings are what the start records. If its
390
+ contribution differs from its preview contribution, the start is refused
391
+ with `E_LAUNCH_PREPARATION` and nothing is stopped or started.
392
+
393
+ Some values only a real run can know, such as a credential minted at start.
394
+ A preview answer may list those names in `volatileEnv` (for example
395
+ `"volatileEnv": ["AWEB_IDENTITY_HOME"]`, beside `env`). For those names:
396
+
397
+ - The start takes the values from the real run, records them, and renders
398
+ the launch command again with them.
399
+ - The comparison leaves those values out. Everything else must still be
400
+ identical.
401
+
402
+ Each name must be one the same hook returned in `env`. Preflight sees only
403
+ the preview's value, so a volatile name must not affect how the harness
404
+ resolves its packages. The kernel refuses (`E_LAUNCH_PREPARATION`) a volatile
405
+ `CLAUDE_CONFIG_DIR`, `CODEX_HOME` or `PI_CODING_AGENT_DIR`. It reads
406
+ `volatileEnv` only from a preview answer and never records it.
407
+
408
+ **A hook that does not declare preview awareness** runs once per start, for
409
+ real, during preflight, before the checks that use its contribution. A
410
+ refused start may therefore already have run it. `oats launch-config preview`
411
+ never runs it. The preview shows that capability's recorded contribution, and
412
+ its `capabilities` check says the hook was not run.
413
+
414
+ `launchPreview` is a top-level key so that a kernel older than 0.37 ignores
415
+ it and runs the hook once, as it always did. A key inside the hook's
416
+ declaration would make such a kernel refuse the whole package.
417
+
367
418
  Hook environment values are strings, at most 8192 UTF-8 bytes, with no NUL or
368
419
  newlines. Names use the portable environment grammar and must belong to an
369
420
  unambiguous vendor namespace. Only a dotted capability ID participates: its
@@ -61,6 +61,10 @@
61
61
  "type": "boolean",
62
62
  "description": "Workspace discovery: true makes this member capability repo-owned — listed (private: true) but usable only by souls of its own repository (E_CAPABILITY_PRIVATE elsewhere)."
63
63
  },
64
+ "launchPreview": {
65
+ "type": "boolean",
66
+ "description": "true declares the launch hook preview-aware: it changes nothing under OATS_LAUNCH_PREVIEW=1 and returns the same contribution (apart from its preview answer's volatileEnv values), so a start runs it as a preview during preflight and for real after it, and a launch preview runs it. Without it the hook runs once per start, for real, during preflight, and never for a launch preview. Top-level so that older kernels ignore it."
67
+ },
64
68
  "layer": {
65
69
  "enum": [
66
70
  "knowledge",
@@ -81,10 +81,10 @@ refused (`E_WORKSPACE_SCHEMA`).
81
81
  How teams are resolved, and what a messaging provider does with them, is in
82
82
  [workspaces.md](workspaces.md#teams).
83
83
 
84
- OATS 0.37.0 (team model 3) removes `souls.teams` and `souls.default` (they move
84
+ OATS 0.38.0 (team model 3) removes `souls.teams` and `souls.default` (they move
85
85
  to `souls:` in `oats-workspace.yaml`) and allows `teams` and `defaultTeam` here
86
- only when the workspace file says `localTeams: true`. 0.36.x still applies all
87
- four keys and warns about them (`team-model-3-migration`): see
86
+ only when the workspace file says `localTeams: true`. 0.36.x and 0.37.x still apply all
87
+ four keys and warn about them (`team-model-3-migration`): see
88
88
  [Preparing for team model 3](workspaces.md#preparing-for-team-model-3-036x).
89
89
 
90
90
  ## Launch configurations
@@ -242,7 +242,12 @@ recipe is resolved again against the home's recorded context and every check
242
242
  runs first. With `--reselect-launch`, the launch preferences decide again
243
243
  (the home's recorded soul and this deployment's `souls.launch`). A capability that contributed harness-specific arguments must
244
244
  declare a `launch` hook to follow a harness change; otherwise the start is
245
- refused (`E_LAUNCH_PREPARATION`). A launch hook's warnings do not stop
245
+ refused (`E_LAUNCH_PREPARATION`). The checks use the preview run
246
+ (`OATS_LAUNCH_PREVIEW=1`) of the launch hooks whose capabilities declare
247
+ `launchPreview`. Those hooks run for real only after every check has passed,
248
+ so a start refused by preflight has run none of them for real. Other launch
249
+ hooks run once, for real, during the checks (see
250
+ [capabilities.md](capabilities.md)). A launch hook's warnings do not stop
246
251
  the start: `session start|restart` print them (and answer them as
247
252
  `warnings` under `--json`), as spawn does, and each is kept as a
248
253
  `launch-warning` instance event (`oats instance events`).
@@ -1229,7 +1229,7 @@ for a failure and `false` for a warning, plus the problem's own keys.
1229
1229
  | `default-team-changed` | warning | `recorded`, `current` | `--home` with live teams: the default changed since the spawn |
1230
1230
  | `E_TEAM_UNKNOWN` | failure | `label`, `at` | a reference to an undeclared label |
1231
1231
  | `E_TEAM_NOT_ELIGIBLE` | failure | `soul`, `label`, `at` | `souls.default` outside the soul's teams |
1232
- | `team-model-3-migration` | warning | `condition`, `keys` | 0.36.x: what OATS 0.37.0 (team model 3) refuses, one item per condition (below) |
1232
+ | `team-model-3-migration` | warning | `condition`, `keys` | 0.36.x and 0.37.x: what OATS 0.38.0 (team model 3) refuses, one item per condition (below) |
1233
1233
 
1234
1234
  The `E_TEAM_UNKNOWN` and `E_TEAM_NOT_ELIGIBLE` codes are also spawn, preview and
1235
1235
  inspect refusals, with the same details.
@@ -1249,7 +1249,7 @@ carries it, and `oats teams` lists it once. Its `condition`:
1249
1249
 
1250
1250
  ```json
1251
1251
  {"code":"team-model-3-migration","severity":"warning","condition":"local-teams-closed","keys":["teams","defaultTeam"],
1252
- "message":"oats-local.yaml declares teams, defaultTeam, but oats-workspace.yaml does not say localTeams: true: OATS 0.37.0 refuses local teams and a local defaultTeam unless the workspace allows them",
1252
+ "message":"oats-local.yaml declares teams, defaultTeam, but oats-workspace.yaml does not say localTeams: true: OATS 0.38.0 refuses local teams and a local defaultTeam unless the workspace allows them",
1253
1253
  "fix":"either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml"}
1254
1254
  ```
1255
1255
 
package/docs/desktop.md CHANGED
@@ -104,6 +104,39 @@ opened, or your home directory when there is none (never ~/Downloads);
104
104
 
105
105
  Launch flags for scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
106
106
 
107
+ ### One window per workspace
108
+
109
+ Each workspace has at most one window, titled with the workspace's name (a
110
+ workspace on a server: `name — server`).
111
+
112
+ - **Switching.** Choosing a workspace in the switcher shows it in the current
113
+ window. If that workspace already has a window, that window comes to the
114
+ front instead and the current one doesn't change.
115
+ - **Open in new window.** Each workspace in the switcher has an **Open in new
116
+ window** button beside it. From the keyboard: Right Arrow on the workspace,
117
+ then Enter, or ⌘Enter on macOS / Ctrl+Enter on Linux and Windows. A
118
+ workspace that already has a window is brought to the front.
119
+ - **New Window.** On macOS, **File → New Window** (⌘⇧N); everywhere,
120
+ **Window: new window** in the command palette. The new window has no
121
+ workspace yet: it opens the switcher, and shows "Choose a workspace" until
122
+ you pick one. It reads nothing until then.
123
+ - **Moving between windows.** On macOS, ⌘\` cycles the app's windows, and the
124
+ **Window** menu lists them.
125
+ - **Restore.** Quitting and relaunching brings every window back with its
126
+ size and place (moved onto a visible display if its own is gone). A window
127
+ whose workspace isn't served at launch doesn't come back, but it is
128
+ remembered until you close it while its workspace is served. With nothing
129
+ to restore, one window opens on the last workspace you used.
130
+ - **Launching again.** Running the app again (`open -a "OATS Desktop" --args
131
+ --dir <deployment>`, or from inside a deployment) adds that deployment if
132
+ needed and brings its window to the front, opening one if it has none. Any
133
+ other launch brings the most recently used window to the front.
134
+ - **Closing.** Closing a window leaves the other windows, and their
135
+ terminals, running. Closing the last window quits the app.
136
+ - Tabs belong to their window: a workspace's terminals and tabs stay in the
137
+ window they were opened in, and switching that window back to the
138
+ workspace brings them back.
139
+
107
140
  ## One workspace, several machines
108
141
 
109
142
  The switcher lists each workspace once, however many deployments it has: the
@@ -41,7 +41,7 @@ arrives from.
41
41
  # oats-workspace.yaml: one default per slot, for every soul
42
42
  packages:
43
43
  oats.okf: v4.1.1
44
- oats.aweb: v1.17.7
44
+ oats.aweb: v1.18.1
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
47
47
  defaults:
@@ -29,17 +29,17 @@
29
29
  },
30
30
  "defaultTeam": {
31
31
  "$ref": "#/$defs/label",
32
- "description": "Team model 3 (0.37.0): the workspace's fallback default team, a label of teams: in this file. 0.36.x accepts and validates it without applying it."
32
+ "description": "Team model 3 (0.38.0): the workspace's fallback default team, a label of teams: in this file. 0.36.x and 0.37.x accept and validate it without applying it."
33
33
  },
34
34
  "localTeams": {
35
35
  "type": "boolean",
36
- "description": "Team model 3 (0.37.0): whether deployments may declare their own teams and defaultTeam in oats-local.yaml. Absent: false. 0.36.x accepts it without applying it."
36
+ "description": "Team model 3 (0.38.0): whether deployments may declare their own teams and defaultTeam in oats-local.yaml. Absent: false. 0.36.x and 0.37.x accept it without applying it."
37
37
  },
38
38
  "souls": {
39
39
  "type": "object",
40
40
  "propertyNames": { "type": "string", "pattern": "^(?:\\*|[a-z0-9][a-z0-9._-]*/(?:\\*|[a-z0-9]+(?:-[a-z0-9]+)*))$" },
41
41
  "additionalProperties": { "$ref": "#/$defs/soulTeams" },
42
- "description": "Team model 3 (0.37.0): per soul pattern (\"*\", <member|package>/* or <member|package>/<soul>; the most specific key wins outright), the soul's default team and the other teams it may join. Every label is a label of teams: in this file. 0.36.x accepts and validates it without applying it."
42
+ "description": "Team model 3 (0.38.0): per soul pattern (\"*\", <member|package>/* or <member|package>/<soul>; the most specific key wins outright), the soul's default team and the other teams it may join. Every label is a label of teams: in this file. 0.36.x and 0.37.x accept and validate it without applying it."
43
43
  },
44
44
  "defaults": { "$ref": "#/$defs/defaults" },
45
45
  "stores": {
@@ -9,9 +9,9 @@ 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.2` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
12
+ | `oats.framework` | `oats-framework/v1.4.3` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
13
  | `oats.okf` | `v4.1.1` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
- | `oats.aweb` | `v1.17.7` | `oats.aweb` (messaging) | |
14
+ | `oats.aweb` | `v1.18.1` | `oats.aweb` (messaging) | |
15
15
  | `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
16
16
  | `oats.authoring` | `v1.0.3` | `oats.authoring` | |
17
17
  | `oats.jira` | `v1.0.1` | `oats.jira` (tasks) | |
package/docs/packages.md CHANGED
@@ -51,7 +51,7 @@ packages:
51
51
  - **Bare version** (`v4.1.1`, `4.1.1`, `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.1.1` or `oats-framework/v1.4.2`) and the payload path. An id
54
+ convention (`v4.1.1` or `oats-framework/v1.4.3`) 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,9 +74,9 @@ members:
74
74
  - git:github.com/acme/agents
75
75
  - git:github.com/acme/platform
76
76
  packages:
77
- oats.framework: v1.4.2
77
+ oats.framework: v1.4.3
78
78
  oats.okf: v4.1.1
79
- oats.aweb: v1.17.7
79
+ oats.aweb: v1.18.1
80
80
  teams:
81
81
  platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
82
82
  defaults:
@@ -134,7 +134,7 @@ Declaring a package in the workspace's `packages:` is the trust decision
134
134
  ## `oats package add | remove`
135
135
 
136
136
  ```bash
137
- oats package add oats.aweb v1.17.7 # a catalog version
137
+ oats package add oats.aweb v1.18.1 # a catalog version
138
138
  oats package add acme.tools git:github.com/acme/tools@v0.4.0
139
139
  oats package remove acme.tools
140
140
  ```
@@ -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.1.1", "path": "oats-package" },
335
- "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.2", "path": "oats-package" }
335
+ "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.3", "path": "oats-package" }
336
336
  }
337
337
  }
338
338
  ```
339
339
 
340
- `ref` carries the tag convention: a workspace's `oats.framework: v1.4.2`
341
- resolves to tag `oats-framework/v1.4.2`. Resolving through the catalog never
340
+ `ref` carries the tag convention: a workspace's `oats.framework: v1.4.3`
341
+ resolves to tag `oats-framework/v1.4.3`. Resolving through the catalog never
342
342
  advances a lock by itself: `oats sync` does, and says so.
@@ -0,0 +1,111 @@
1
+ # OATS 0.37.0
2
+
3
+ ## Added
4
+
5
+ - **The Desktop opens one window per workspace** (awebai/oats#481). Each
6
+ workspace has at most one window, titled with its name (a workspace on a
7
+ server: `name — server`). Choosing a workspace in the switcher shows it in the
8
+ current window, or brings its own window to the front when it has one.
9
+ Each workspace in the switcher has an **Open in new window** button
10
+ (keyboard: Right Arrow then Enter, or ⌘Enter / Ctrl+Enter). On macOS,
11
+ **File → New Window** (⌘⇧N) opens a window with no workspace that asks you
12
+ to choose one, and ⌘\` cycles the windows; on Linux and Windows the command
13
+ palette has **Window: new window**, with no default chord. Quitting and
14
+ relaunching brings every window back with its size and place; a window
15
+ whose workspace isn't served at launch stays remembered until you close it
16
+ while it is served. Launching the app again with `--dir <deployment>`
17
+ brings that deployment's window to the front, adding the deployment if
18
+ needed. An unfocused window re-reads its instance list at the server's
19
+ blurred cadence (30 s). A window's requests always carry its own
20
+ workspace: one the server doesn't serve is refused with
21
+ `E_WORKSPACE_NOT_SERVED`, never answered with another workspace's data.
22
+ Window positions are kept in `windows.json` in the app's data folder.
23
+
24
+ ## Changed
25
+
26
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.38.0`**, so it runs against
27
+ this release's kernel. Install the CLI and the Desktop 0.37.0 together: the
28
+ Desktop 0.36.x refuses a 0.37 CLI.
29
+
30
+ - **Team model 3 is OATS 0.38.0, not 0.37.0.** 0.37.0 keeps 0.36.1's
31
+ behaviour: the new `oats-workspace.yaml` keys are validated but not applied,
32
+ and the `team-model-3-migration` warning, which now names 0.38.0, says what
33
+ 0.38.0 will refuse. The migration steps in the 0.36.1 notes apply unchanged.
34
+
35
+ - **oats.framework 1.4.3** (oats.setup 2.2.2, catalog and workspace pin
36
+ `oats-framework/v1.4.3`): the `oats-teams` and
37
+ `oats-workspace-config` skills say team model 3 is 0.38.0, and that 0.36.x
38
+ and 0.37.x keep applying the local team keys. Remove them only when the
39
+ deployment moves to 0.38.0.
40
+
41
+ - **oats.aweb 1.18.1** (catalog and workspace pin, and the bundled mirror):
42
+ how mail reaches a session now depends on the runtime. Under the default
43
+ `delivery: channel`, Claude Code takes mail and chat through its
44
+ `aweb-channel` plugin and pi through the `@awebai/pi` extension, and
45
+ neither goes through the host wake broker. Codex keeps the wake broker. A
46
+ Codex home under `channel` is now registered with it at every start, where
47
+ before it got no wake at all. Every start of a home re-decides the path for
48
+ that start's runtime and leaves the home on exactly one path. A start that
49
+ cannot register or deregister the home is refused. Existing instances keep
50
+ the delivery they were spawned with until they are respawned.
51
+ `delivery: session` still sends every runtime through the broker.
52
+
53
+ oats.aweb 1.18.1 declares `launchPreview` (see Fixed below). A launch
54
+ preview, including Desktop's start dialog, and a start refused by
55
+ preflight change no broker registration: the hook calls `aw` only in the
56
+ real run, after preflight has passed.
57
+
58
+ ## Fixed
59
+
60
+ - **Launch hooks no longer have side effects under a preview, or before a
61
+ start's preflight has passed** (awebai/oats#500). A launch hook may register
62
+ a home with its provider; oats.aweb's registers it with the host wake
63
+ broker. But `oats launch-config preview`, which Desktop's start dialog
64
+ calls for running homes, ran every hook for real. A start refused by
65
+ preflight, including a start of a home that was already running, had also
66
+ run them. Previewing a running Claude home with another harness could
67
+ therefore change how that live session is woken.
68
+
69
+ A capability now declares that its launch hook is preview-aware, with
70
+ `"launchPreview": true` at the top level of its manifest. The kernel reads
71
+ the declaration from the home's own module copy. A preview-aware hook
72
+ runs twice per start:
73
+ - **As a preview,** with `OATS_LAUNCH_PREVIEW=1` in the hook environment
74
+ (a new, additive hook environment variable). This run's contribution
75
+ drives the preflight and the rendered command.
76
+ - **For real, without the flag,** only once every check has passed, and
77
+ before a restart stops the running harness.
78
+
79
+ A preview runs only the first pass. A preview-aware hook must change
80
+ nothing under the flag. It must also return the same contribution either
81
+ way, apart from the values of the env names its preview answer lists in
82
+ `volatileEnv` (below). If the real run's contribution differs otherwise,
83
+ the start is refused with `E_LAUNCH_PREPARATION` and nothing is stopped or
84
+ started.
85
+
86
+ A hook that doesn't declare `launchPreview` runs as it did in 0.36: once
87
+ per start, for real, during preflight. `oats launch-config preview` never
88
+ runs it; the preview shows its recorded contribution and says so. The
89
+ kernel never passes on an `OATS_LAUNCH_PREVIEW` inherited from the
90
+ environment.
91
+
92
+ **Provider authors:** declare `launchPreview` once your launch hook
93
+ honours `OATS_LAUNCH_PREVIEW`, and expect it to run twice per start
94
+ (docs/capabilities.md). oats.aweb 1.18.1 does. A kernel before 0.37
95
+ ignores the key and runs the hook once, as before.
96
+ - **Existing homes whose launch hook renews a credential at every start
97
+ start again** (awebai/oats#504). The first version of the two-pass rule
98
+ above ran every launch hook twice. A hook from before the preview flag
99
+ then really ran under the preview too, and a hook that answers new values
100
+ at every call failed the comparison. That covers oats.aweb homes with
101
+ `identity.mode: global` and `renew: launch`, which mint a grant at each
102
+ start: their starts were refused with `E_LAUNCH_PREPARATION`. Two passes
103
+ are now opt-in, through `launchPreview`.
104
+
105
+ A preview-aware hook can also name, in its preview answer, env whose
106
+ values only the real run can know, such as `"volatileEnv":
107
+ ["AWEB_IDENTITY_HOME"]`. The start takes those values from the real run
108
+ and leaves them out of the comparison. Each must be a name the same hook
109
+ returned. A harness configuration selector (`CLAUDE_CONFIG_DIR`,
110
+ `CODEX_HOME`, `PI_CODING_AGENT_DIR`) is refused as volatile, because the
111
+ package probe reads it before the real run.
@@ -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.2 # bare version → resolves through the official catalog
57
+ oats.framework: v1.4.3 # bare version → resolves through the official catalog
58
58
  oats.okf: v4.1.1
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
@@ -361,10 +361,10 @@ Exact shapes: [desktop-cli-api.md](desktop-cli-api.md#team-model-v2-feature-team
361
361
 
362
362
  ### Preparing for team model 3 (0.36.x)
363
363
 
364
- OATS 0.37.0 commits a soul's teams in the workspace (team model 3,
364
+ OATS 0.38.0 commits a soul's teams in the workspace (team model 3,
365
365
  awebai/oats#484): the teams an organisation's instances may join become its
366
366
  own decision, visible and reviewable in its git, so a deployment's
367
- `oats-local.yaml` no longer adds one by accident. 0.36.x prepares for it, so
367
+ `oats-local.yaml` no longer adds one by accident. 0.36.x and 0.37.x prepare for it, so
368
368
  every workspace and deployment can migrate first:
369
369
 
370
370
  - **`oats-workspace.yaml` accepts the new keys** and validates them, but
@@ -385,7 +385,7 @@ every workspace and deployment can migrate first:
385
385
  the same file; anything else is `E_WORKSPACE_SCHEMA` when the file is read. A
386
386
  key naming a member or package the workspace does not have is not an error.
387
387
  - **The readiness warning `team-model-3-migration`** (never blocking) names
388
- what 0.37.0 will refuse: `souls.teams` / `souls.default` in `oats-local.yaml`
388
+ what 0.38.0 will refuse: `souls.teams` / `souls.default` in `oats-local.yaml`
389
389
  (they move to `souls:`), and local `teams` / `defaultTeam` while the workspace
390
390
  does not say `localTeams: true` (fix: add `localTeams: true`, or commit the
391
391
  teams and `defaultTeam` in the workspace file). `oats teams`, readiness (and
package/lib/core.mjs CHANGED
@@ -1574,8 +1574,12 @@ export function teamEnv(resolved) {
1574
1574
  OATS_WORKSPACE_NAME: typeof ws.name === "string" ? ws.name : "", OATS_WORKSPACE_KEY: typeof ws.key === "string" ? ws.key : "",
1575
1575
  };
1576
1576
  }
1577
+ function withoutAmbientLaunchPreview(env) {
1578
+ const { OATS_LAUNCH_PREVIEW: _ambient, ...rest } = env;
1579
+ return rest;
1580
+ }
1577
1581
  export function runLifecycleHooks(event, { home, instance, agentName, soulDir, soulId, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
1578
- const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
1582
+ const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], volatileEnv: {} };
1579
1583
  // OATS_SOUL is set for EVERY hook: a caller that does not name the soul (launch,
1580
1584
  // retire) gets the directory the home's spawn recorded.
1581
1585
  if (!soulDir && home) soulDir = instanceSoulDir(home);
@@ -1596,7 +1600,9 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1596
1600
  const stdout = execSync(cmd, {
1597
1601
  cwd: home, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000,
1598
1602
  env: {
1599
- ...process.env,
1603
+ // OATS_LAUNCH_PREVIEW is the kernel's to give (prepareLaunchHooks), never
1604
+ // inherited: a real start's hook must not read an ambient one as a preview.
1605
+ ...withoutAmbientLaunchPreview(process.env),
1600
1606
  // OATS_INSTANCE_HOME is the runtime-neutral contract name for the
1601
1607
  // instance home (absolute). OATS_HOME predates it and stays as a
1602
1608
  // compatibility alias: shipped capability hooks read it
@@ -1624,6 +1630,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1624
1630
  let o = {};
1625
1631
  try { o = JSON.parse(lastLine); } catch { /* non-JSON hook output is fine */ }
1626
1632
  if (o.meta) results.meta[cap.id] = o.meta;
1633
+ // A launch hook's answer as given; prepareLaunchHooks validates it.
1634
+ if (event === "launch" && o.volatileEnv !== undefined) results.volatileEnv[cap.id] = o.volatileEnv;
1627
1635
  if (o.brief) results.briefs.push(`- ${o.brief}`);
1628
1636
  if (o.warning) results.warnings.push(o.warning);
1629
1637
  if (o.launch && typeof o.launch === "object") for (const [rt, args] of Object.entries(o.launch)) results.launch[rt] = `${results.launch[rt] ? `${results.launch[rt]} ` : ""}${args}`;
@@ -2468,7 +2476,10 @@ function requirementsWithArgsMessage(harness, providers, config) {
2468
2476
  /** ONE planner for what a start would run, used by preview and by starts of
2469
2477
  * existing homes alike: the recorded recipe (or, under a selection, the
2470
2478
  * current scoped configuration) resolved, preflighted, rendered. `preview`
2471
- * collects every failed check into `preflight` instead of throwing. A home
2479
+ * collects every failed check into `preflight` instead of throwing. Launch
2480
+ * hooks of capabilities declaring `launchPreview` run here only as a preview
2481
+ * (OATS_LAUNCH_PREVIEW=1); the others run here for real when a start plans,
2482
+ * and not at all for a preview. A home
2472
2483
  * that records no launch recipe (an earlier kernel spawned it) is not planned:
2473
2484
  * E_LAUNCH_LEGACY, re-spawn it from the deployment. */
2474
2485
  export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null }) {
@@ -2514,14 +2525,17 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2514
2525
  // Recorded contributions, refreshed by capabilities that declare a
2515
2526
  // launch hook; a harness change needs the new harness's arguments from
2516
2527
  // every capability that gave harness-specific ones; recorded arguments
2517
- // of a capability the scope no longer trusts are not reused.
2528
+ // of a capability the scope no longer trusts are not reused. Hooks that
2529
+ // declare launchPreview run here under OATS_LAUNCH_PREVIEW=1, for a start
2530
+ // too (it runs them for real once its preflight passed); the others run
2531
+ // here for real on a start, and not at all for a preview.
2518
2532
  try {
2519
- hooks = prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, assertRoots });
2533
+ hooks = prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, assertRoots, pass: preview ? "preview" : "plan" });
2520
2534
  const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
2521
2535
  const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
2522
2536
  const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
2523
2537
  if (untrusted.length) fail("capabilities", "E_LAUNCH_PREPARATION", `${untrusted.join(", ")} contributed to this launch at spawn but is no longer trusted in the scope; respawn the instance; nothing was stopped`);
2524
- else problems.push({ check: "capabilities", ok: true, detail: `${harness !== frozen.harness ? "prepared for the new harness" : "recorded contributions reused"}${hooks.refreshed?.length ? `; refreshed by launch hooks: ${hooks.refreshed.join(", ")}` : ""}${inactive.length ? `; no longer active in the scope, recorded contribution kept: ${inactive.join(", ")}` : ""}` });
2538
+ else problems.push({ check: "capabilities", ok: true, detail: `${harness !== frozen.harness ? "prepared for the new harness" : "recorded contributions reused"}${hooks.refreshed?.length ? `; refreshed by launch hooks: ${hooks.refreshed.join(", ")}` : ""}${hooks.notRun?.length ? `; recorded contribution shown, launch hook not run for a preview (not preview-aware): ${hooks.notRun.join(", ")}` : ""}${inactive.length ? `; no longer active in the scope, recorded contribution kept: ${inactive.join(", ")}` : ""}` });
2525
2539
  } catch (e) {
2526
2540
  if (e.code !== "E_LAUNCH_PREPARATION") throw e;
2527
2541
  fail("capabilities", e.code, e.message);
@@ -2574,7 +2588,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2574
2588
  const { trustHome } = launchFolderTrust({ harness, home, meta, yolo: recipe.yolo, env });
2575
2589
  const command = renderLaunchRecipe(recipe, { home, instance: inst, trustHome });
2576
2590
  const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.harness) ? "frozen" : "config") : "config";
2577
- return { recipe, command, trustHome, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
2591
+ return { recipe, command, trustHome, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], previewedHooks: hooks.previewed || [], volatileEnv: hooks.volatileEnv || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
2578
2592
  }
2579
2593
  /** The environment a planned launch runs under: the host's base, the
2580
2594
  * capabilities' validated env, the configuration's literals and its
@@ -4863,10 +4877,24 @@ export function capturedProviders(meta, frozen) {
4863
4877
  }
4864
4878
  /** Capability contributions for a start of an existing home: the recorded
4865
4879
  * ones, refreshed by any capability that declares a `launch` hook (asked
4866
- * for the target harness; side-effect-free by contract; spawn hooks are
4867
- * never re-run). A harness change needs the new harness's launch arguments
4868
- * from every capability that contributed harness-specific ones. */
4869
- export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
4880
+ * for the target harness; spawn hooks are never re-run). A harness change
4881
+ * needs the new harness's launch arguments from every capability that
4882
+ * contributed harness-specific ones.
4883
+ * The hook contract: a launch hook may do idempotent provider registration
4884
+ * on a real start. A capability whose manifest (the home's module copy)
4885
+ * declares `launchPreview: true` promises that its hook changes nothing
4886
+ * under OATS_LAUNCH_PREVIEW=1 and returns the same contribution (launch
4887
+ * arguments and env) either way, except for the env names its preview
4888
+ * answer lists in `volatileEnv`, whose values only the real run knows.
4889
+ * `pass` says which hooks run, and how:
4890
+ * - "preview" (a launch preview): declaring hooks under the flag; the others
4891
+ * do not run, and their recorded contributions stand (`notRun`);
4892
+ * - "plan" (a start's preflight): declaring hooks under the flag, the others
4893
+ * once, for real;
4894
+ * - "real" (a start after its preflight passed): declaring hooks for real,
4895
+ * over `frozen.hooks` = the planned contributions; startInstanceSession
4896
+ * refuses a contribution that differs from the preview's. */
4897
+ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots, pass = "plan" }) {
4870
4898
  const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
4871
4899
  const env = { ...(frozen.hooks?.env || {}) };
4872
4900
  const refreshed = [];
@@ -4889,20 +4917,44 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
4889
4917
  const hooks = manifestHookCommands(manifest);
4890
4918
  if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, settingsOrigins: p.settingsOrigins, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
4891
4919
  }
4892
- if (withLaunchHook.length) {
4893
- const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...extraEnv } });
4894
- const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
4920
+ const aware = withLaunchHook.filter((c) => c.manifest.launchPreview === true);
4921
+ const unaware = withLaunchHook.filter((c) => c.manifest.launchPreview !== true);
4922
+ const runs = (pass === "real" ? [[aware, false]] : pass === "preview" ? [[aware, true]] : [[unaware, false], [aware, true]]).filter(([caps]) => caps.length);
4923
+ const notRun = pass === "preview" ? unaware.map((c) => c.id) : [];
4924
+ const previewed = runs.filter(([, asPreview]) => asPreview).flatMap(([caps]) => caps.map((c) => c.id));
4925
+ const volatileEnv = [];
4926
+ if (runs.length) {
4927
+ const results = runs.map(([caps, asPreview]) => ({ asPreview, res: runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: caps }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...(asPreview ? { OATS_LAUNCH_PREVIEW: "1" } : {}), ...extraEnv } }) }));
4928
+ const failed = results.flatMap(({ res }) => res.failures || []).map((f) => `${f.capability}: ${f.message}`);
4895
4929
  if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${harness} launch:\n ${failed.join("\n ")}`);
4930
+ const fresh = results.flatMap(({ res }) => res.contributions || []);
4896
4931
  // Ownership holds across retained AND refreshed contributions, as the
4897
4932
  // spawn runner holds it across providers: a refreshed provider may
4898
- // replace its own previous keys, never a key another provider retains.
4899
- const refreshedIds = new Set((res.contributions || []).map((c) => c.capability));
4933
+ // replace its own previous keys, never a key another provider retains,
4934
+ // nor one another provider's hook set in this pass.
4935
+ const refreshedIds = new Set(fresh.map((c) => c.capability));
4900
4936
  const retainedOwner = new Map();
4901
4937
  for (const c of contributions) if (c.capability && !refreshedIds.has(c.capability)) for (const name of c.env || []) retainedOwner.set(name, c.capability);
4902
- for (const c of res.contributions || []) for (const name of c.env || []) {
4938
+ const freshOwner = new Map();
4939
+ for (const c of fresh) for (const name of c.env || []) {
4903
4940
  if (retainedOwner.has(name)) throw oatsError("E_LAUNCH_PREPARATION", `${c.capability}'s launch hook set ${name}, which ${retainedOwner.get(name)} contributed at spawn and retains; one provider owns an environment name; nothing was stopped`);
4941
+ if (freshOwner.has(name) && freshOwner.get(name) !== c.capability) throw oatsError("E_LAUNCH_PREPARATION", `${c.capability}'s launch hook set ${name}, which ${freshOwner.get(name)}'s launch hook also set; one provider owns an environment name; nothing was stopped`);
4942
+ freshOwner.set(name, c.capability);
4943
+ }
4944
+ // A preview answer may name env whose values only the real run knows
4945
+ // (a credential minted at start). Each is a name the same hook returned,
4946
+ // and never a harness's configuration selector: the package probe reads
4947
+ // that under the preview's value before the real run.
4948
+ for (const { asPreview, res } of results) if (asPreview) for (const [id, names] of Object.entries(res.volatileEnv || {})) {
4949
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook answered volatileEnv that is not an array of environment names; nothing was stopped`);
4950
+ const returned = (res.contributions || []).find((c) => c.capability === id)?.env || [];
4951
+ for (const name of names) {
4952
+ if (!returned.includes(name)) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook declared ${name} volatile but did not return it in env; nothing was stopped`);
4953
+ if (HARNESS_CONFIG_SELECTORS.has(name)) throw oatsError("E_LAUNCH_PREPARATION", `${id}'s launch hook declared ${name} volatile, but ${name} selects a harness's configuration, which the start's package probe reads before the real run; a volatile value must not affect harness package resolution; nothing was stopped`);
4954
+ volatileEnv.push(name);
4955
+ }
4904
4956
  }
4905
- for (const c of res.contributions || []) {
4957
+ for (const c of fresh) {
4906
4958
  const idx = contributions.findIndex((x) => x.capability === c.capability);
4907
4959
  // The provider's previous contribution goes whole, its env names
4908
4960
  // included, before its new (validated) one is merged: an empty answer
@@ -4912,23 +4964,32 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
4912
4964
  if (idx >= 0) contributions[idx] = row; else contributions.push(row);
4913
4965
  refreshed.push(c.capability);
4914
4966
  }
4915
- Object.assign(env, res.env || {});
4967
+ for (const { res } of results) Object.assign(env, res.env || {});
4968
+ // What the caller records comes from real runs only; a launch preview
4969
+ // answers its own runs' warnings.
4970
+ const recorded = results.filter(({ asPreview }) => asPreview === (pass === "preview")).map(({ res }) => res);
4916
4971
  // A launch hook's `meta` is part of its documented return (the same shape
4917
4972
  // spawn persists as capabilityMeta). It was collected and then dropped
4918
4973
  // here, so a provider that re-issues a credential at every start — a
4919
4974
  // renewed session grant, for instance — left the ORIGINAL id on record and
4920
4975
  // retire undid the wrong one. Carry it to the caller; the start records it.
4921
- hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
4976
+ const metaOf = Object.assign({}, ...recorded.map((res) => res.meta || {}));
4977
+ hookMeta = Object.keys(metaOf).length ? metaOf : undefined;
4922
4978
  // The hooks' advisory warnings go to the start's answer and events, as
4923
4979
  // spawn's do; they were dropped here before 0.30.
4924
- warnings = [...(res.warnings || [])];
4980
+ warnings = recorded.flatMap((res) => res.warnings || []);
4925
4981
  }
4926
- const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && c.launch && c.launch[frozen.harness] !== undefined && c.launch[harness] === undefined).map((c) => c.capability);
4982
+ // A hook not run for a preview keeps its recorded contribution: whether it
4983
+ // has arguments for the target harness is its own run's answer, at start.
4984
+ const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && !notRun.includes(c.capability) && c.launch && c.launch[frozen.harness] !== undefined && c.launch[harness] === undefined).map((c) => c.capability);
4927
4985
  if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.harness} launch arguments at spawn and none for ${harness}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
4928
4986
  const launch = {};
4929
4987
  for (const c of contributions) { for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
4930
- return { launch, env, contributions, refreshed, warnings, ...(hookMeta ? { meta: hookMeta } : {}) };
4988
+ return { launch, env, contributions, refreshed, notRun, previewed, volatileEnv, warnings, ...(hookMeta ? { meta: hookMeta } : {}) };
4931
4989
  }
4990
+ /** Environment that selects which configuration (account, packages) a
4991
+ * harness reads, under which the package probe inspects its packages. */
4992
+ const HARNESS_CONFIG_SELECTORS = new Set(["CLAUDE_CONFIG_DIR", "CODEX_HOME", "PI_CODING_AGENT_DIR"]);
4932
4993
 
4933
4994
 
4934
4995
  export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
@@ -5093,7 +5154,7 @@ export function startInstanceSession(home, o = {}) {
5093
5154
  const reselect = o.reselectLaunch === true ? homeLaunchLayers(realHome, meta) : null;
5094
5155
  const selected = reselect !== null || o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
5095
5156
  const hasRecipe = meta.launch && typeof meta.launch === "object";
5096
- let launchPlan = null, explicitModelFrom = null, warnings = [];
5157
+ let launchPlan = null, launchHooksPass = null, hookMeta, explicitModelFrom = null, warnings = [];
5097
5158
  if (selected || hasRecipe) {
5098
5159
  // Every start of a home with a recipe (ordinary, model-only, or under a
5099
5160
  // selection) goes through the one planner: recipe shape, the recorded
@@ -5106,11 +5167,15 @@ export function startInstanceSession(home, o = {}) {
5106
5167
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, defaultTeam: o.defaultTeam, teamsSource: o.teamsSource });
5107
5168
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
5108
5169
  const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
5109
- launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }), ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}),
5170
+ launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
5110
5171
  ...(plan.launchChoice ? { launchFrom: plan.launchChoice.from, launchAt: plan.launchChoice.at, launchDeclared: plan.launchChoice.declared } : {}) };
5172
+ // The plan ran preview-aware launch hooks as a preview: their real run, over the planned
5173
+ // contributions, waits for the rest of preflight.
5174
+ if (plan.previewedHooks.length) launchHooksPass = { frozen: { ...plan.frozen, hooks: plan.recipe.hooks }, harness: plan.harness, resolvedCfg, contextDir: context, previewed: plan.recipe.hooks, volatileEnv: plan.volatileEnv, trustHome: plan.trustHome };
5111
5175
  command = launchPlan.command; model = launchPlan.model;
5112
- // The launch hooks have run: their warnings are events now, whatever
5113
- // the start does next, and the answer carries them.
5176
+ // The other launch hooks ran for real in the plan: their warnings are
5177
+ // events now, whatever the start does next, and the answer carries them.
5178
+ hookMeta = plan.hookMeta;
5114
5179
  warnings = plan.warnings;
5115
5180
  for (const message of warnings) appendEvent(realHome, { kind: "launch-warning", data: { message } });
5116
5181
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
@@ -5127,10 +5192,7 @@ export function startInstanceSession(home, o = {}) {
5127
5192
  if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
5128
5193
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
5129
5194
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
5130
- checkRoots(); // launch hooks/preparation have run; no backend has been observed
5131
- const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
5132
- ...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
5133
- ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}) } : (explicitModelFrom ? { modelFrom: explicitModelFrom } : {});
5195
+ checkRoots(); // preparation has run; no backend has been observed
5134
5196
  let target = receipt.target;
5135
5197
  let state = { present: false, state: "not-launched" };
5136
5198
  let serverGone = false;
@@ -5141,9 +5203,38 @@ export function startInstanceSession(home, o = {}) {
5141
5203
  else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
5142
5204
  }
5143
5205
  }
5206
+ if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
5207
+ // Every preflight has passed: the preview-aware launch hooks run for real
5208
+ // (they may register the home with their provider), before a restart's
5209
+ // stop, and must contribute exactly what they contributed as a preview,
5210
+ // which is what the command was rendered and preflighted from, except the
5211
+ // values of the env their preview declared volatile: those come from this
5212
+ // run, and the command is rendered again with them.
5213
+ if (launchHooksPass) {
5214
+ const { previewed, volatileEnv, trustHome, ...pass } = launchHooksPass;
5215
+ const real = prepareLaunchHooks({ ...pass, home: realHome, meta, assertRoots: checkRoots, pass: "real" });
5216
+ const canon = (v) => canonicalJson(JSON.parse(JSON.stringify(v ?? null)));
5217
+ const stable = (h) => Object.fromEntries(Object.entries(h.env).filter(([n]) => !volatileEnv.includes(n)));
5218
+ if (canon({ launch: real.launch, env: stable(real), contributions: real.contributions }) !== canon({ launch: previewed.launch, env: stable(previewed), contributions: previewed.contributions })) {
5219
+ // A provider's row names its env; the values are in the merged env.
5220
+ const rowOf = (h, id) => { const row = h.contributions.find((c) => c.capability === id); return canon({ row, values: (row?.env || []).map((n) => volatileEnv.includes(n) ? null : h.env[n]) }); };
5221
+ const differing = [...new Set([...real.contributions, ...previewed.contributions].map((c) => c.capability))].filter((id) => rowOf(real, id) !== rowOf(previewed, id));
5222
+ throw oatsError("E_LAUNCH_PREPARATION", `the launch hook of ${differing.join(", ") || "a capability"} returned a contribution that differs from its preview contribution (a launch hook returns the same contribution under OATS_LAUNCH_PREVIEW, apart from the values of its volatileEnv); nothing was stopped or started`);
5223
+ }
5224
+ launchPlan.recipe = { ...launchPlan.recipe, hooks: { launch: real.launch, env: real.env, contributions: real.contributions } };
5225
+ command = launchPlan.command = renderLaunchRecipe(launchPlan.recipe, { home: realHome, instance: meta.instance, trustHome });
5226
+ if (real.meta) hookMeta = { ...(hookMeta || {}), ...real.meta };
5227
+ // The real run's warnings are events now, whatever the start does next,
5228
+ // and the answer carries them.
5229
+ warnings = [...warnings, ...real.warnings];
5230
+ for (const message of real.warnings) appendEvent(realHome, { kind: "launch-warning", data: { message } });
5231
+ checkRoots();
5232
+ }
5233
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
5234
+ ...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
5235
+ ...(hookMeta ? { hookMeta } : {}) } : (explicitModelFrom ? { modelFrom: explicitModelFrom } : {});
5144
5236
  let stopReceipt = null;
5145
5237
  if (state.present && state.state !== "shell") {
5146
- if (!o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
5147
5238
  // Restart: every preflight above passed, so ask the running harness to
5148
5239
  // end and wait, bounded. A harness still there afterwards is reported
5149
5240
  // as running; nothing is escalated and nothing is launched.
package/lib/schedule.mjs CHANGED
@@ -516,7 +516,7 @@ export function childEnv() {
516
516
  for (const key of [...RESERVED_LAUNCH_ENV,
517
517
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
518
518
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
519
- "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
519
+ "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_LAUNCH_PREVIEW", "OATS_RETIRE_INTENT",
520
520
  "OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_TEAMS", "OATS_TEAMS_SOURCE", "OATS_TRIGGER_EVENT_FILE",
521
521
  ]) delete env[key];
522
522
  return env;
package/lib/teams.mjs CHANGED
@@ -18,8 +18,8 @@
18
18
  * the default first, then by label. Reports carry unmapped rows (team null); OATS_TEAMS and
19
19
  * instance.json carry mapped rows only. DefaultTeam { label, team, from: deployment|soul } | null.
20
20
  *
21
- * Team model 3 (0.37.0, awebai/oats#484) moves these choices into the committed workspace file. 0.36.x
22
- * validates its keys (lib/workspace.mjs) without applying them, and warns about what 0.37.0 will refuse
21
+ * Team model 3 (0.38.0, awebai/oats#484) moves these choices into the committed workspace file. 0.36.x and
22
+ * 0.37.x validate its keys (lib/workspace.mjs) without applying them, and warn about what 0.38.0 will refuse
23
23
  * (migrationProblems: team-model-3-migration).
24
24
  */
25
25
  import { oatsError } from "./errors.mjs";
@@ -63,7 +63,7 @@ export function teamModel(workspace, local, { workspaceKey = null } = {}) {
63
63
  labels: new Map([...localTeams, ...shared]), // the committed definition wins a collision
64
64
  defaultTeam: str(local?.defaultTeam),
65
65
  souls: { teams: isObject(souls.teams) ? souls.teams : {}, default: isObject(souls.default) ? souls.default : {} },
66
- // Team model 3 (0.37.0) moves these keys; 0.36.x only warns (migrationProblems). `localTeams` is
66
+ // Team model 3 (0.38.0) moves these keys; 0.36.x and 0.37.x only warn (migrationProblems). `localTeams` is
67
67
  // the workspace's answer, null without a workspace file (the standalone view has no workspace rules).
68
68
  migration: {
69
69
  soulKeys: ["teams", "default"].filter((k) => has(local?.souls, k)).map((k) => `souls.${k}`),
@@ -146,21 +146,21 @@ const refusalProblem = (e) => ({ code: e.code, ...e.details, severity: "failure"
146
146
  * surface (oats teams, readiness, doctor) carries them. */
147
147
  export const MIGRATION_CODE = "team-model-3-migration";
148
148
  /**
149
- * The team-model-3-migration warnings (0.36.x; 0.37.0 refuses what they name): `local-soul-teams` when
149
+ * The team-model-3-migration warnings (0.36.x and 0.37.x; 0.38.0 refuses what they name): `local-soul-teams` when
150
150
  * oats-local.yaml has souls.teams / souls.default, `local-teams-closed` when it declares teams /
151
151
  * defaultTeam and the workspace file does not say `localTeams: true` (never without a workspace file).
152
152
  */
153
153
  export function migrationProblems(model) {
154
154
  const m = model.migration, problems = [];
155
155
  if (m.soulKeys.length) problems.push({ code: MIGRATION_CODE, severity: "warning", condition: "local-soul-teams", keys: [...m.soulKeys],
156
- message: `oats-local.yaml ${m.soulKeys.join(", ")}: OATS 0.37.0 refuses ${m.soulKeys.length > 1 ? "these keys" : "this key"}; which teams a soul may join, and its default, move to souls: in oats-workspace.yaml`,
156
+ message: `oats-local.yaml ${m.soulKeys.join(", ")}: OATS 0.38.0 refuses ${m.soulKeys.length > 1 ? "these keys" : "this key"}; which teams a soul may join, and its default, move to souls: in oats-workspace.yaml`,
157
157
  fix: `commit the same choices as souls: entries in oats-workspace.yaml ("*" or <member|package>/<soul>: { default, teams }), then remove ${m.soulKeys.join(" and ")} from oats-local.yaml` });
158
158
  if (m.teamKeys.length && m.localTeams === false) problems.push(localTeamsClosedProblem(m.teamKeys));
159
159
  return problems;
160
160
  }
161
161
  /** `local-teams-closed` for the oats-local.yaml `keys` found (doctor builds it from its offline read). */
162
162
  export const localTeamsClosedProblem = (keys) => ({ code: MIGRATION_CODE, severity: "warning", condition: "local-teams-closed", keys: [...keys],
163
- message: `oats-local.yaml declares ${keys.join(", ")}, but oats-workspace.yaml does not say localTeams: true: OATS 0.37.0 refuses local teams and a local defaultTeam unless the workspace allows them`,
163
+ message: `oats-local.yaml declares ${keys.join(", ")}, but oats-workspace.yaml does not say localTeams: true: OATS 0.38.0 refuses local teams and a local defaultTeam unless the workspace allows them`,
164
164
  fix: "either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml" });
165
165
 
166
166
  /**
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.17.7",
11
+ "ref": "v1.18.1",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
@@ -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.2",
36
+ "ref": "oats-framework/v1.4.3",
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.36.1",
3
+ "version": "0.37.0",
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.2 # bare versions resolve through the official catalog
63
+ oats.framework: v1.4.3 # bare versions resolve through the official catalog
64
64
  oats.okf: v4.1.1
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }