@awebai/oats 0.32.0 → 0.33.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.
package/README.md CHANGED
@@ -208,7 +208,7 @@ Begin with a small team and a real piece of work:
208
208
  4. Create instances for their assignments.
209
209
  5. Verify that work, communication, learning and handoff behave as intended.
210
210
 
211
- On a machine with Node.js 22+, Git and tmux, and a workspace repository to point at:
211
+ On a machine with Node.js 22+, Git (2.45+ to fetch only what OATS reads; an older git fetches whole trees) and tmux, and a workspace repository to point at:
212
212
 
213
213
  ```bash
214
214
  npm install -g @awebai/oats
package/bin/oats.mjs CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
29
29
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
30
30
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
31
- findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, validateLaunchConfigDefaults, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
+ findInstanceHome, findInstanceHomes, enclosingInstanceHome, logicalCwd, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, validateLaunchConfigDefaults, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
32
32
  } from "../lib/core.mjs";
33
33
  import {
34
34
  writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
@@ -789,7 +789,7 @@ function launchPreview(bail) {
789
789
  let plan;
790
790
  try { plan = planLaunch({ home, instance, meta, contextDir: context, agentLike, selection: sel, resolvedCfg: r, preview: true }); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
791
791
  const { recipe } = plan;
792
- const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
792
+ const command = renderLaunchRecipe(recipe, { home, instance, redact: true, trustHome: plan.trustHome });
793
793
  const d = describeLaunchCommand(command);
794
794
  const environment = d.environment.map((e) => e.reference && recipe.env[e.name]?.fromEnv ? { name: e.name, fromEnv: recipe.env[e.name].fromEnv } : e);
795
795
  jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, harness: sel.harness ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, harness: plan.harness, model: recipe.model, modelSource: plan.modelSource, yolo: recipe.yolo ?? null, launchConfig: recipe.launchConfig, launchConfigSource: recipe.launchConfigSource, launchConfigDefault: recipe.launchConfigDefault === true, executable: { path: plan.executable.path, declared: plan.executable.declared ?? null, resolvedFrom: plan.executable.resolvedFrom }, argv: d.argv, environment, command, prompt: recipe.prompt, hooks: redactLaunchRecipe(recipe).hooks, preflight: plan.preflight, ok: plan.ok });
@@ -1030,7 +1030,7 @@ let maxAgeGiven = null;
1030
1030
  function commandSession() {
1031
1031
  if (!readSession) {
1032
1032
  readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0 });
1033
- process.on("exit", () => readSession.closeNow());
1033
+ process.on("exit", () => { sayReadNotices(); readSession.closeNow(); });
1034
1034
  for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) process.once(signal, () => {
1035
1035
  readSession.closeNow();
1036
1036
  // Another handler (a scheduler lock's release) exits on its own after this one.
@@ -1039,15 +1039,22 @@ function commandSession() {
1039
1039
  }
1040
1040
  return readSession;
1041
1041
  }
1042
+ /** What the read session found worth telling the operator (a remote that cannot serve partial fetches),
1043
+ * once, on stderr when the command ends: stdout, and so every JSON answer, is unchanged. */
1044
+ function sayReadNotices() {
1045
+ for (const notice of readSession?.notices.splice(0) ?? []) process.stderr.write(`oats: warning: ${notice}\n`);
1046
+ }
1042
1047
  /** Which kernel command forms take --max-age: THE allow-list (docs/desktop-cli-api.md "Observation reuse").
1043
1048
  * → null when this form reads with observation reuse, else the E_BAD_ARGS message. `head` is argv before `--`. */
1044
- const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, and the read forms of teams and soul teams";
1049
+ const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
1045
1050
  function maxAgeRefusal(command, head) {
1046
1051
  const word = (i) => (head[i] !== undefined && !head[i].startsWith("--") ? head[i] : undefined);
1047
1052
  const refuse = (form) => `--max-age is not accepted by \`oats ${form}\`: only the read verbs reuse observations (${MAX_AGE_READS})`;
1048
1053
  if (head.includes("--server")) return "--max-age cannot be combined with --server: observation reuse is local to this machine";
1049
1054
  switch (command) {
1050
1055
  case "status": case "souls": case "capabilities": case "inspect": return null;
1056
+ // A preview reads (feature spawn-preview-max-age); an apply always observes live.
1057
+ case "spawn": return head.includes("--preview") ? null : refuse("spawn");
1051
1058
  case "workspace": return word(1) === "status" ? null : refuse(["workspace", word(1)].filter(Boolean).join(" "));
1052
1059
  case "teams": return word(1) === undefined ? null : refuse(`teams ${word(1)}`);
1053
1060
  case "soul": {
@@ -1884,8 +1891,9 @@ async function status() {
1884
1891
  const livenessWord = (i) => i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown";
1885
1892
  /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1886
1893
  * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1887
- * host through `--server`, whose route reads it. */
1888
- const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1894
+ * host through `--server`, whose route reads it. `--max-age` reaches here only on a preview: the
1895
+ * dispatch allow-list (maxAgeRefusal) refuses it on an apply. */
1896
+ const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "idempotency-key", "instance", "launch-config", "max-age", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1889
1897
  const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1890
1898
  /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1891
1899
  * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
@@ -2141,7 +2149,7 @@ async function spawnCmd() {
2141
2149
  // A workspace preview may have fetched the soul's SOURCE to a temporary copy
2142
2150
  // (the deployment's cache had no entry for its commit): the result says so.
2143
2151
  if (prepared) r.soulFetched = soulFetched;
2144
- if (JSON_MODE) { jsonOk(r); return; }
2152
+ if (JSON_MODE) { jsonOk(withObservation(r)); return; }
2145
2153
  console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) harness ${r.harness}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}${r.launchConfig ? ` via launch configuration ${r.launchConfig}${r.launchConfigDefault ? ` (this machine's ${r.harness} default)` : ""}` : ""}${r.yolo ? " YOLO" : ""}; nothing was created${soulFetched ? " (the soul source was fetched to a temporary copy, not kept)" : ""}`);
2146
2154
  return;
2147
2155
  }
@@ -2758,8 +2766,9 @@ async function capabilityCommand() {
2758
2766
  let activeIds;
2759
2767
  let context = process.cwd();
2760
2768
  let teamCtx, homeMeta, homeTeamCtx;
2761
- // OATS_INSTANCE_HOME is the canonical identity; the older names still count.
2762
- const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME;
2769
+ // OATS_INSTANCE_HOME is the canonical identity; the older names still count. With none set
2770
+ // (a harness that strips the session env), the home enclosing the cwd.
2771
+ const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME || enclosingInstanceHome(logicalCwd());
2763
2772
  const metaFile = instanceHome && join(instanceHome, "instance.json");
2764
2773
  // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
2765
2774
  // braces: the ids come from instance.json, which spawn wrote from resolved
@@ -2937,7 +2946,7 @@ function versionCmd() {
2937
2946
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2938
2947
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2939
2948
  // never listed.
2940
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "launch-config-default"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2949
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2941
2950
  return;
2942
2951
  }
2943
2952
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3543,6 +3552,9 @@ Usage:
3543
3552
  [--provider <capability> <key>=<value>] a provider setting for this spawn only
3544
3553
  (repeatable; dotted keys nest; recorded in
3545
3554
  instance.json providers.<capability>)
3555
+ [--preview [--max-age <s>]] decide everything, create nothing; the JSON's
3556
+ decision binds an apply (--expect-decision <rev>);
3557
+ --max-age reuses recent heads (preview only)
3546
3558
  oats retire <instance> [--force] retire an instance (window, hooks,
3547
3559
  [--self] [--delete-branch] worktree, home); --self = retire the
3548
3560
  [--keep-dir] [--json] CALLING instance: the window dies, then
@@ -3660,15 +3672,17 @@ The turn record (core — every conversation captured, searchable, replicated):
3660
3672
 
3661
3673
  Observation reuse (feature observe-max-age):
3662
3674
  --max-age <seconds> on the read verbs only — status, workspace status,
3663
- souls, capabilities, inspect --soul|--home, and the
3664
- read forms of teams and soul teams — reuse a remote
3665
- head observation up to <seconds> old (0–86400; 0 is
3666
- live) instead of asking the remote again; the JSON
3675
+ souls, capabilities, inspect --soul|--home,
3676
+ spawn --preview (feature spawn-preview-max-age),
3677
+ and the read forms of teams and soul teams —
3678
+ reuse a remote head observation up to <seconds>
3679
+ old (0–86400; 0 is live) instead of asking the
3680
+ remote again; the JSON
3667
3681
  then carries observation { observedAt (the oldest
3668
3682
  head used), reused, localRevision (a digest of
3669
3683
  the local configuration read) }. Refused
3670
3684
  (E_BAD_ARGS) by every other command, an edit form,
3671
- and with --server
3685
+ a spawn apply, and with --server
3672
3686
 
3673
3687
  Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
3674
3688
  }
@@ -3681,5 +3695,6 @@ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspa
3681
3695
  die(e.message);
3682
3696
  } finally {
3683
3697
  // The command's read session: every `git cat-file --batch` child ends before the process does.
3698
+ sayReadNotices();
3684
3699
  if (readSession) await readSession.close();
3685
3700
  }
@@ -355,6 +355,11 @@ for this contract. Hyphenated vendors are also excluded because translating a
355
355
  hyphen to `_` would let `aweb-evil.*` collide with names already inside
356
356
  `aweb.*`'s `AWEB_*` namespace.
357
357
 
358
+ Hook environment values must not be secrets. A codex launch also passes them
359
+ to Codex as command-line arguments (`-c shell_environment_policy.set.<NAME>=…`,
360
+ so its tool commands see them), and any local user can read those. A secret
361
+ reaches a launch through a launch configuration's environment reference.
362
+
358
363
  A manifest's `settings.<key>` may carry `hostOnly: true`. Such a
359
364
  key is a fact about the machine — a custody directory, a state root — and the
360
365
  resolver accepts it only from the deployment's own `oats-local.yaml`
@@ -330,6 +330,6 @@ Environment: `OATS_REMOTE_CACHE` relocates the fetch cache (which also holds
330
330
  the bounded parsed-read cache and the observations `--max-age` reuses; all of
331
331
  it is safe to delete); `OATS_PACKAGE_CATALOG` names an alternative package
332
332
  catalog file. The read verbs (`status`, `workspace status`, `souls`,
333
- `capabilities`, `inspect`, and the read forms of `teams` and `soul teams`)
334
- take `--max-age <seconds>` to reuse a remote head observed that recently
333
+ `capabilities`, `inspect`, the read forms of `teams` and `soul teams`, and
334
+ `spawn --preview`) take `--max-age <seconds>` to reuse a remote head observed that recently
335
335
  ([Observation reuse](desktop-cli-api.md#observation-reuse-feature-observe-max-age-oats-0311)).
@@ -55,8 +55,9 @@ symlink enters as `symlink:<target>`; empty directories and a top-level `.git/`
55
55
  ### 1.3 Access, cache, failures
56
56
 
57
57
  - Git uses the operator's own configuration and credentials; `GIT_TERMINAL_PROMPT=0`,
58
- `GIT_ASKPASS=/usr/bin/false` and ssh `-o BatchMode=yes` mean nothing prompts. 30 s per git call.
59
- - A commit is fetched depth 1 (no blob filter) into a bare cache `<cacheDir>/<sha256(key)>/` (default
58
+ `GIT_ASKPASS=/usr/bin/false` and ssh `-o BatchMode=yes` mean nothing prompts. 30 s per git call; 10 minutes for the fetch of a commit.
59
+ - A commit is fetched depth 1 with its trees and its blobs up to 64 KiB (larger blobs on demand, when a read
60
+ needs them; whole trees from a server without partial fetches; awebai/oats#384) into a bare cache `<cacheDir>/<sha256(key)>/` (default
60
61
  `~/.cache/oats/remotes`; the CLI honours `OATS_REMOTE_CACHE`) and pinned as `refs/oats/commits/<oid>`.
61
62
  The cache may be wiped at any time. Operations on one cache repo are serialized.
62
63
  - Failures: `E_REMOTE_UNREADABLE { url, key, reason }`, `reason` ∈ `auth`, `not-found`, `network`,
@@ -38,7 +38,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
38
38
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
39
39
  "workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
40
40
  "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
41
- "preview-composed-from","observe-max-age"],
41
+ "preview-composed-from","observe-max-age","spawn-preview-max-age"],
42
42
  "automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
43
43
  "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2}
44
44
  ```
@@ -96,6 +96,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
96
96
  | `launch-preference` | soul and local launch preferences; `launch`, `launchCurrent`, `launchFrom`; `--reselect-launch`; `key` on soul and agent rows ([Launch preferences](#soul-launch-preferences-feature-launch-preference-oats-0300)) | |
97
97
  | `preview-composed-from` | `composedFrom` on preview `modules[]` ([Composition](#the-preview)) | |
98
98
  | `observe-max-age` | `--max-age <s>` on the read verbs and their `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311)) | |
99
+ | `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
99
100
 
100
101
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
101
102
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -194,6 +195,7 @@ it and answer live, without the block.
194
195
  ```text
195
196
  oats status | workspace status | souls | capabilities | inspect --soul|--home
196
197
  | teams | soul teams <soul> … --max-age <seconds> --json
198
+ oats spawn <soul> … --preview --max-age <seconds> --json (feature spawn-preview-max-age)
197
199
  ```
198
200
 
199
201
  - **Values:** whole seconds, `0` to `86400`. `0` is live: it reuses nothing.
@@ -206,7 +208,15 @@ oats status | workspace status | souls | capabilities | inspect --soul|--home
206
208
  everywhere; `reused` is `true` when any head came from an earlier observation.
207
209
  A command that read no remote head reports the time it started and
208
210
  `reused: false`. Without the flag the key is absent and every document is
209
- exactly as before.
211
+ exactly as before. A refusal (`E_SOUL_UNKNOWN`, any error envelope) never
212
+ carries the block.
213
+ - **Spawn preview** (feature `spawn-preview-max-age`, OATS 0.33.0): the
214
+ preview's `result` gains the same block, so you can say "as of
215
+ `<observedAt>`". The `decision` covers the heads the preview used, reused
216
+ or live: a reused head the member has since moved from makes the apply
217
+ (which always observes live) refuse `E_DECISION_STALE`, and the apply
218
+ records the head it observed, so the next preview under `--max-age` shows
219
+ it with a new `decision.revision`. The apply itself refuses the flag.
210
220
  - **`localRevision`:** 24 lowercase hex characters, opaque. It digests every
211
221
  piece of local configuration the kernel read for this answer:
212
222
  `oats-local.yaml` (and each closer `oats-local.yaml` it looked for and did
@@ -237,12 +247,14 @@ oats status | workspace status | souls | capabilities | inspect --soul|--home
237
247
  spelling of the same repository (ssh vs https) or for a different ref; its
238
248
  commit can no longer be fetched. Each is observed live, as without the flag.
239
249
  A live observation that fails is the usual error, never an older head.
240
- - **Refusals:** every other command, every edit form (`teams add|remove|default`,
250
+ - **Refusals:** every other command, a spawn apply (with or without
251
+ `--expect-decision`; the form named is `spawn`), every edit form (`teams add|remove|default`,
241
252
  `soul teams --add|--remove|--default|--clear-default`) and any `--server`
242
253
  invocation refuse the flag before reading or writing anything, with
243
254
  `E_BAD_ARGS` "--max-age is not accepted by \`oats <form>\`: only the read
244
255
  verbs reuse observations (status, workspace status, souls, capabilities,
245
- inspect --soul|--home, and the read forms of teams and soul teams)" and,
256
+ inspect --soul|--home, spawn --preview, and the read forms of teams and soul
257
+ teams)" and,
246
258
  with `--server`, "--max-age cannot be combined with --server: observation
247
259
  reuse is local to this machine".
248
260
  A capability command's argv (`oats <namespace> …`) is its provider's: the
@@ -523,7 +535,25 @@ Feature `workspace-v2`, `workspaceApi: 2`. Model: [workspaces.md](workspaces.md)
523
535
  `E_LOCAL_MISSING {dir, searched}`.
524
536
  - The workspace is read over Git remotes with the operator's credentials,
525
537
  never prompting: `E_REMOTE_UNREADABLE {url, reason: "auth" | "not-found" |
526
- "network" | "timeout"}`.
538
+ "network" | "timeout" | "killed" | "cache" | "unknown"}`. `killed` (the
539
+ system killed git, for example out of memory) also carries `signal`.
540
+ `cache` (OATS 0.33.0) is local: the
541
+ remote cache on this machine could not be written (a git lock still held,
542
+ another oats process still writing it, or a lock file one left when it
543
+ died); `details.cacheDir` and, when known, `details.lock`,
544
+ `details.guard` or `details.holderPid` say which, and the message says
545
+ what to do.
546
+ - **How the Desktop reads it** (0.33.0). Of an `E_REMOTE_UNREADABLE` from
547
+ `status` / `workspace status`, the Desktop keeps the `message` (shown as
548
+ given) and only a bounded cause: `details.reason` (matching
549
+ `^[a-z][a-z-]{0,31}$`) and the host of `details.url`. No path, pid, lock,
550
+ `cacheDir` or other detail field crosses to the renderer. It keys only on
551
+ `code` + `details.reason`: `cache` words the roster "OATS cache
552
+ problem" with the message in full; `network` / `timeout` read "Couldn't
553
+ reach <host>"; any other reason keeps the generic wording. When the
554
+ deployment was observed before, the failed read keeps that observation:
555
+ `/api/panel` serves it with `error` (the message) and `errorCause {code,
556
+ reason, host?}`, and the roster shows it stale instead of empty.
527
557
  - There is no package approval: declaring a package is the trust decision.
528
558
  No payload carries `approvalNeeded`, `approval` or `approved`.
529
559
  - A **standalone view** is a member repository whose workspace is not read
@@ -1162,7 +1192,7 @@ the result with `oats inspect --soul <name> --json`.
1162
1192
  ### The preview
1163
1193
 
1164
1194
  ```text
1165
- oats spawn <soul> [the flags of a real spawn] --preview --json
1195
+ oats spawn <soul> [the flags of a real spawn] --preview [--max-age <s>] --json
1166
1196
  ```
1167
1197
 
1168
1198
  Feature `spawn-preview-2`, `spawnPreviewApi: 2`. The preview runs every
@@ -1250,6 +1280,20 @@ it to a temporary copy (`soulFetched: true`).
1250
1280
  `payloadRevision` (the merged payloads). `workspace` is the host key;
1251
1281
  `standalone` marks a standalone view. `task` is the task text or `null`.
1252
1282
 
1283
+ **Observation reuse** (feature `spawn-preview-max-age`, OATS 0.33.0).
1284
+ - `--preview --max-age <s>` reuses member heads this machine observed at
1285
+ most `<s>` seconds ago, as the read verbs do ([Observation
1286
+ reuse](#observation-reuse-feature-observe-max-age-oats-0311): the same
1287
+ values, refusals and fallbacks to a live observation). With the flag (`0`
1288
+ included) the result gains `observation: {observedAt, reused,
1289
+ localRevision}`, shaped exactly as the read verbs' block; without it the
1290
+ preview is exactly as before, and no other field changes shape.
1291
+ - `decision.revision` covers the heads the preview used, reused or live.
1292
+ Apply never reuses (it refuses `--max-age`): a head that moved since the
1293
+ reused observation refuses `E_DECISION_STALE`, and the apply records what
1294
+ it observed, so re-preview under `--max-age` to get the new head and
1295
+ revision.
1296
+
1253
1297
  **Provider settings.**
1254
1298
  - `providers` is the `--provider` map as typed.
1255
1299
  - `settings.<cap>`: the merged payload (manifest defaults, then workspace,
@@ -80,6 +80,23 @@ Host-owned provider values (absolute paths, state roots) go under `settings:` in
80
80
  `oats-local.yaml` afterwards — never in the workspace file, whose schema refuses
81
81
  them. Do not commit `oats-local.yaml`.
82
82
 
83
+ **Trust the deployment once, for unattended launches.** Claude Code and Codex
84
+ ask before they work in a folder they have not seen, and every instance home is
85
+ new: a launch that stops at that prompt waits for a human. OATS never writes
86
+ the harnesses' configuration, so trust the deployment directory yourself, once
87
+ per harness you use:
88
+
89
+ ```bash
90
+ cd ~/acme && claude # accept the folder-trust prompt, then quit
91
+ cd ~/acme && codex # choose "Trust and continue", then quit
92
+ ```
93
+
94
+ One entry covers every instance home under the deployment
95
+ ([souls-and-instances.md](souls-and-instances.md#unattended-launches-folder-trust)
96
+ says how each harness applies it). Until then, a claude or codex spawn warns
97
+ that its session will stop at the folder-trust prompt, and so does
98
+ `oats readiness`.
99
+
83
100
  ## 3. Give the deployment a team
84
101
 
85
102
  With a messaging capability in the soul's composition, every instance lives in
@@ -117,8 +134,9 @@ oats spawn backend-expert --purpose first-fix --task "Fix one small issue, run t
117
134
  oats status
118
135
  ```
119
136
 
120
- `--harness pi|claude|codex` picks the harness; complete any native folder
121
- trust or authentication prompt in the printed session. The instance home is
137
+ `--harness pi|claude|codex` picks the harness; complete any native
138
+ authentication prompt in the printed session (folder trust is the one-time step
139
+ in section 2). The instance home is
122
140
  `agents/<soul>/instances/<instance>/`; `work/` is its repository view;
123
141
  `.oats/modules/<cap>/` are the copied capabilities and `.agents/skills/<skill>/` their skills;
124
142
  `instance.json` records `modules` (from, commit, digest), `providers` and
@@ -30,7 +30,7 @@ published to npm. Its developer docs are in
30
30
  | `lib/` | the kernel (below) |
31
31
  | `injects/` | the kernel and work-mode instruction blocks composed into every instance |
32
32
  | `skills/` | bootstrap skills shipped with the kernel |
33
- | `capabilities/` | this repository's own member capabilities (`oats-workspace-experts`), discovered at the member's latest state |
33
+ | `capabilities/` | this repository's own member capabilities (`oats-desktop-ui`, `oats-workspace-experts`), discovered at the member's latest state |
34
34
  | `mirrors/` | generated byte mirrors of the official packages' capabilities (for example `oats-okf*`, checked by `scripts/check-okf-mirror.mjs`): release-lane and test material, kept out of `capabilities/` so member discovery does not list them a second time; not shipped in the npm package |
35
35
  | `oats-package/` | the `oats.framework` package |
36
36
  | `souls/` | this repository's own souls (a workspace member) |
@@ -58,6 +58,7 @@ published to npm. Its developer docs are in
58
58
  | `tmux-config.mjs`, `session-*.mjs` | the tmux session backend and terminal input |
59
59
  | `capability-contract.mjs`, `provider-binding.mjs` | manifest validation, the hook environment rules, the readiness wire |
60
60
  | `servers.mjs` | routing commands to a registered server |
61
+ | `harness-trust.mjs` | reading (never writing) Claude's and Codex's folder trust for a launch |
61
62
 
62
63
  The kernel is runtime-neutral: nothing in `lib/` depends on a harness or on
63
64
  a provider. Provider behaviour lives in capabilities; the kernel supplies
@@ -93,7 +94,29 @@ gets the plain per-call behaviour. Within a session:
93
94
  - a commit's tree is listed once (`git ls-tree -r -t -l`, bounded by
94
95
  `TREE_INDEX_BUDGET`; anything odd falls back to the per-path reads), and
95
96
  blobs come from one `git cat-file --batch` reader per cache repo (at most
96
- 12 open, killed through `process-group.mjs` on timeout and at close);
97
+ 12 open, ended through `process-group.mjs` on timeout and at close);
98
+ `fetchRemoteTree` copies a module through it too, once `ensureBlobs` has
99
+ fetched what was missing (git re-reads its packs on a miss, so a reader
100
+ opened earlier finds the new blobs; one still missing answers `missing`,
101
+ never a fetch), with what is left of `TREE_BUDGET` as each read's bound;
102
+ a blob the reader answers `missing` or over its bound is read once more
103
+ alone, so the error is the one a copy without a session gives. A
104
+ command that ends normally awaits the close, so its readers are reaped
105
+ before it exits; a `process.exit` (every refusal) ends them in the
106
+ exit hook (`closeNow`), and the system reaps them once the process is gone;
107
+ - every git child is ended with SIGTERM first and SIGKILL only after a
108
+ grace (`terminateGroup`): git removes its own lock files on SIGTERM, and
109
+ a git killed outright leaves one that blocks every later write. The
110
+ SIGKILL goes to the whole group even when git itself has exited, so a
111
+ descendant that ignores SIGTERM (ssh, a remote helper) still ends; but
112
+ never to a group seen empty, whose id may already lead an unrelated
113
+ group. Until git's `close` (`watchGroup`), a member holding its pipes
114
+ keeps the id ours; after it, the group is probed every 50 ms through the
115
+ grace (no pid is allocated while it is a live group's id): empty, and it
116
+ is never signalled again. git's pipes are drained on a kill, never
117
+ destroyed, so `close` keeps waiting for a pipe-holding descendant. The exit
118
+ hook cannot wait for a timer, so it waits a bounded 200 ms synchronously
119
+ (`reapOnExit`);
97
120
  - discovery reads members eight at a time (`DISCOVERY_CONCURRENCY`) with
98
121
  serial results: declaration order, the first failure in that order. The
99
122
  observations and the member reads are two pools, so a discovery runs at
@@ -102,6 +125,45 @@ gets the plain per-call behaviour. Within a session:
102
125
  shared pool would deadlock: a member read holding a slot waits on its
103
126
  member's observation, which needs a slot of its own.
104
127
 
128
+ The cache repos are partial: a commit is fetched with all its trees and
129
+ only the blobs up to `SMALL_BLOB_LIMIT` (64 KiB), which covers every file
130
+ discovery reads, so listings and discovery stay local after one fetch. A
131
+ read that needs a larger blob, or a `fetchRemoteTree` of a module, fetches
132
+ the missing blobs first in one fetch by id (`ensureBlobs`), then applies the
133
+ budgets to their real sizes before anything is written. git never fetches a
134
+ blob lazily (`GIT_NO_LAZY_FETCH=1`, and no url is stored in the cache: each
135
+ fetch passes it with `-c remote.origin.url=`). A server without partial
136
+ fetches gets whole trees; the cache records that (`oats.fetch = full` in its
137
+ config) and the CLI prints the session's notice once, on stderr. Partial
138
+ caches need git 2.45 or later (`PARTIAL_FETCH_GIT`, the first git with
139
+ `GIT_NO_LAZY_FETCH`): with an older git every cache fetches whole trees, a
140
+ partial cache it meets is deleted and fetched again whole, and the same
141
+ notice says why.
142
+
143
+ Every write to a cache repo (its `git init`, config, fetches and pins) holds
144
+ the repo's cross-process write lock, `<cache>/.locks/<repo>.lock`
145
+ (`withCacheWriteLock`): an exclusive file holding `{pid, token, startedAt}`,
146
+ waited for while its holder lives (bounded by a whole fetch, then
147
+ `reason: "cache"` naming the pid), reclaimed when the holder is dead, and
148
+ released only by its owner. Reclaimers take a short guard,
149
+ `<lock>.reclaim`, and check under it that the lock is still the dead
150
+ record before removing it, so a reclaimer that paused cannot delete a
151
+ live process's new lock. A guard whose holder died is never removed
152
+ automatically (that removal would race the same way, with nothing left to
153
+ serialize it): every write refuses at once, `reason: "cache"` naming the
154
+ guard (`details.guard`), until a human removes it once no oats process is
155
+ running. Reads take no lock. A cache repo appears whole
156
+ (`git init` into a private directory, then a rename), so processes making
157
+ the first fetch of one remote all succeed. A git `*.lock` a write meets is
158
+ judged under that lock (`cacheGit`): older oats kernels take no write lock,
159
+ so it is retried briefly, then removed only when it is inside the cache
160
+ repo, a regular file and older than the longest fetch
161
+ (`GIT_FETCH_TIMEOUT_MS` plus a margin): a git killed mid-write. A removal
162
+ is said once as a warning. Anything else is `reason: "cache"` naming the
163
+ file and when it is safe to remove; so is any other local write failure
164
+ (a `FETCH_HEAD` git cannot open, a read-only or full disk), with git's own
165
+ words.
166
+
105
167
  Across commands, `memoAtCommit` keeps parsed reads under
106
168
  `<cache>/.parsed/<kernel fingerprint>/`, keyed by (repo key, full commit,
107
169
  item). The items: `workspace` (the workspace file), `membership` (a member's
@@ -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.0.5
44
- oats.aweb: v1.17.3
44
+ oats.aweb: v1.17.5
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
47
47
  defaults:
@@ -11,8 +11,8 @@ or workspace membership alone does not make a package official.
11
11
  |---|---|---|---|
12
12
  | `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
13
  | `oats.okf` | `v4.0.5` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
- | `oats.aweb` | `v1.17.3` | `oats.aweb` (messaging) | |
15
- | `oats.engineering` | `v1.3.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
14
+ | `oats.aweb` | `v1.17.5` | `oats.aweb` (messaging) | |
15
+ | `oats.engineering` | `v1.4.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) | |
18
18
  | `oats.linear` | `v1.0.1` | `oats.linear` (tasks) | |
package/docs/packages.md CHANGED
@@ -76,7 +76,7 @@ members:
76
76
  packages:
77
77
  oats.framework: v1.4.1
78
78
  oats.okf: v4.0.5
79
- oats.aweb: v1.17.3
79
+ oats.aweb: v1.17.5
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.3 # a catalog version
137
+ oats package add oats.aweb v1.17.5 # 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
  ```