@awebai/oats 0.34.0 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,7 +129,7 @@ full copy** of every capability the soul resolved to:
129
129
  .oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
130
130
  .oats/bin/oats → <kernel>/bin/oats.mjs # the kernel that last launched this home: first on the harness's PATH
131
131
  work/ # worktree, checkout symlink, attached tree, or private directory
132
- TASK.md # briefing and task
132
+ TASK.md # briefing and task (0600; the home itself is 0700)
133
133
  instance.json # provenance (below); `soulDir` = the soul directory hooks get as OATS_SOUL
134
134
  STATE.md, log.md, notes/ # optional, from the knowledge capability
135
135
  ```
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
152
152
  "commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
153
153
  },
154
154
  "oats.okf": {
155
- "from": { "kind": "package", "package": "oats.okf", "version": "4.0.5", "commit": "26d8216f…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
- "commit": "26d8216f…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
155
+ "from": { "kind": "package", "package": "oats.okf", "version": "4.0.6", "commit": "2a62df8e…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
+ "commit": "2a62df8e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
157
157
  }
158
158
  },
159
159
  "providers": {
@@ -245,8 +245,7 @@ OATS codex session does not appear in `codex agents`. So that the environment
245
245
  does not depend on this, a codex launch also sets it for tool
246
246
  commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
247
247
 
248
- - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
249
- `PI_AGENT_HOME`;
248
+ - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`;
250
249
  - every capability's launch environment (for example the messaging
251
250
  provider's identity home and delivery mode);
252
251
  - the launch configuration's literal values. A reference's value never goes
@@ -258,9 +257,9 @@ runs tool commands through the user's login shell, and a profile that prepends
258
257
  directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
259
258
  directory is found first.
260
259
 
261
- A capability command (`oats <namespace> …`) run with none of
262
- `OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
263
- from the working directory. It uses the nearest enclosing directory laid out as
260
+ A capability command (`oats <namespace> …`) runs in the instance home that
261
+ `OATS_INSTANCE_HOME` names, else `OATS_HOME`. With neither set, it finds its
262
+ instance from the working directory. It uses the nearest enclosing directory laid out as
264
263
  `<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
265
264
  name, and validates it like a home named by the environment. The walk uses the
266
265
  directory as the shell names it (`$PWD`). That matters for an attached
@@ -268,6 +267,13 @@ instance, whose `work/` links into its owner's tree: below it, the physical
268
267
  path is the owner's. A process that has no `$PWD` there would act as the
269
268
  owner, so an attached instance runs capability commands from its home.
270
269
 
270
+ Inside an instance home the namespace is that home's. A `--soul` naming
271
+ another soul is refused (`E_HOME_MISMATCH`), and a namespace the home does
272
+ not have is `E_UNKNOWN_COMMAND`. Both name the home and what chose it (the
273
+ variable, or the working directory). To run a command as a spawn of another
274
+ soul would, run it from the deployment with `OATS_INSTANCE_HOME` and
275
+ `OATS_HOME` unset.
276
+
271
277
  ## Lifecycle
272
278
 
273
279
  ### Spawn
@@ -561,9 +567,10 @@ Every instance is told its own home as **`OATS_INSTANCE_HOME`** (absolute), and
561
567
  instructions refer to it as `<instance-home>`. The two environments differ, so
562
568
  they are stated separately:
563
569
 
564
- - **Runtime session**: `OATS_INSTANCE_HOME` and `PI_AGENT_HOME` (plus
565
- `OATS_INSTANCE`/`PI_AGENT_INSTANCE`). The `PI_`-prefixed names are
566
- compatibility aliases for the separately published pi extension.
570
+ - **Runtime session**: `OATS_INSTANCE_HOME` and `OATS_INSTANCE`, for every
571
+ harness. The pi extension reads `OATS_INSTANCE_HOME` too; the `PI_AGENT_*`
572
+ names are not set (they stay reserved, so a launch configuration cannot set
573
+ them).
567
574
  - **Lifecycle hooks**: `OATS_INSTANCE_HOME` and `OATS_HOME`, alongside the rest of
568
575
  the hook contract. `OATS_HOME` predates `OATS_INSTANCE_HOME` and is kept because
569
576
  shipped capability hooks read it; it is **not** exported to harness sessions.
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
55
55
 
56
56
  packages: # the ONLY versioned things
57
57
  oats.framework: v1.4.1 # bare version → resolves through the official catalog
58
- oats.okf: v4.0.5
58
+ oats.okf: v4.0.6
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
61
61
  teams: # SHARED teams: the same provider team for everyone
@@ -174,7 +174,18 @@ from *outside* that boundary, and **declaring one in the workspace's
174
174
  access context.** The kernel reads both halves over the remotes
175
175
  (`git ls-remote`, shallow fetches, the operator's own credential helpers,
176
176
  never a prompt). A half that cannot be read makes the member *unconfirmed*,
177
- never a half-success. `oats workspace status` and `oats sync` show each member
177
+ never a half-success. A remote's current head is read with Git's protocol v0
178
+ (one round trip) unless your Git configuration sets `protocol.version`, which
179
+ is then used as set; a tag or branch is read as before. A remote whose v0 ref
180
+ advertisement is over 4 MiB is read with protocol v2 from then on: the 4 MiB
181
+ bounds what OATS keeps, not the download, so the first read transfers that
182
+ advertisement once before falling back. A remote that times out under v0
183
+ fails as before and is read with protocol v2 for the next 7 days; a read
184
+ ended by the 12 s budget of `oats status` or `oats workspace status` is not
185
+ the remote's own timeout, and is neither remembered nor warned about. One that
186
+ fails under v0 for another reason than an authentication refusal, a missing
187
+ repository or the local cache is read again with v2. Each case prints one
188
+ `oats: warning`; the first two are remembered in the remote cache. `oats workspace status` and `oats sync` show each member
178
189
  as `confirmed` or the reason it is not:
179
190
 
180
191
  | status | meaning |
@@ -103,6 +103,8 @@ export function canonicalJson(value, options) {
103
103
  return chunks.join("");
104
104
  }
105
105
 
106
+ /** The 1-based line of character `offset` in `text`: where a reader's error points. */
107
+ export function lineAt(text, offset) { return text.slice(0, offset).split("\n").length; }
106
108
  /** JSON.parse alone cannot detect duplicate decoded keys. This small JSON
107
109
  * decoder preserves that check and budgets; YAML must use it for JSON input,
108
110
  * not maintain another permissive JSON reader. Objects have null prototypes. */
@@ -114,7 +116,7 @@ export function parseStrictJson(input, options) {
114
116
  }
115
117
  const text = decodeUtf8(data);
116
118
  let at = 0, entries = 0;
117
- const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}`); };
119
+ const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}`, { offset: at }); };
118
120
  const space = () => { while (at < text.length && /[\x20\t\r\n]/.test(text[at])) at++; };
119
121
  const string = () => {
120
122
  const start = at++;
@@ -13,7 +13,7 @@
13
13
  export const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
14
14
  export const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
15
15
  export const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
16
- export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
16
+ export const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
17
17
  export const PROCESS_BOOTSTRAP_ENV = new Set([
18
18
  "PATH", "HOME", "SHELL", "TMPDIR", "TMP", "TEMP", "PWD", "OLDPWD", "SHLVL", "_",
19
19
  "ENV", "BASH_ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "IFS", "PROMPT_COMMAND", "PS4", "ZDOTDIR",
@@ -3,7 +3,7 @@
3
3
  * Legacy core readers are unchanged until the explicit consumer migration. */
4
4
  import { Composer, CST, Lexer, Parser, isAlias, isMap, isScalar, isSeq } from "yaml";
5
5
  import { bytesIntegrity } from "./digest.mjs";
6
- import { byteView, canonicalJson, dataLimits, decodeUtf8, parseStrictJson } from "./canonical-json.mjs";
6
+ import { byteView, canonicalJson, dataLimits, decodeUtf8, lineAt, parseStrictJson } from "./canonical-json.mjs";
7
7
  import { oatsError } from "./errors.mjs";
8
8
 
9
9
  const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -71,7 +71,9 @@ export function parseConfigData(input, { format = "auto", origin = null, limits:
71
71
  }
72
72
  if (document.errors.length || document.warnings.length || document.directives.yaml.version !== "1.2") {
73
73
  const issue = document.errors[0] ?? document.warnings[0];
74
- throw oatsError("invalid-declaration", `invalid portable YAML${issue ? ` (${issue.code})` : " version"}`);
74
+ const offset = issue?.pos?.[0];
75
+ const line = Number.isInteger(offset) ? lineAt(source, offset) : undefined;
76
+ throw oatsError("invalid-declaration", `invalid portable YAML${issue ? ` (${issue.code}${line ? ` at line ${line}` : ""})` : " version"}`);
75
77
  }
76
78
  const decode = (node, pointer, depth) => {
77
79
  mark(pointer, depth, node);
package/lib/core.mjs CHANGED
@@ -63,7 +63,7 @@ import { validateBindingInterface } from "./provider-binding.mjs";
63
63
  /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
64
64
  export { fingerprintTree };
65
65
 
66
- import { canonicalJson, parseStrictJson } from "./canonical-json.mjs";
66
+ import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
67
67
  import { readPortableBytes } from "./bounded-read.mjs";
68
68
  import { copyTreeSafe } from "./tree-copy.mjs";
69
69
  /** The package and capability id grammar (namespaced, lowercase): an id names a
@@ -89,6 +89,10 @@ function capabilityAgentDirs(root) {
89
89
  return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !RESERVED.has(e.name) && !existsSync(join(root, e.name, "soul", "soul.yaml")))
90
90
  .map((e) => ({ name: e.name, dir: join(root, e.name) }));
91
91
  }
92
+ /** The names of the non-hidden directories in `d` ([] when it cannot be read). */
93
+ function subdirs(d) {
94
+ try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; }
95
+ }
92
96
  /** What OATS 0.25 left under <scope>/local-agents/ (and the nested <root>/local-agents|
93
97
  * tmp-agents) for the agents root `root`: one `legacy-local-agents` problem naming the
94
98
  * instance homes there, or null. Names only — nothing there is read, spawned into or
@@ -97,7 +101,6 @@ export function legacyLocalAgents(root) {
97
101
  if (!root) return null;
98
102
  const bases = [join(dirname(root), LEGACY_LOCAL_AGENTS_DIR), join(root, LEGACY_LOCAL_AGENTS_DIR), join(root, "tmp-agents")];
99
103
  const dirs = [], instances = [];
100
- const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
101
104
  for (const base of bases) {
102
105
  let st; try { st = lstatSync(base); } catch { continue; }
103
106
  if (!st.isDirectory()) continue;
@@ -109,12 +112,32 @@ export function legacyLocalAgents(root) {
109
112
  return { code: "legacy-local-agents", dirs, instances,
110
113
  message: `${instances.length} instance home${instances.length === 1 ? "" : "s"} under local-agents/ ${instances.length === 1 ? "is" : "are"} from OATS 0.25 and ${instances.length === 1 ? "is" : "are"} not managed by this kernel; retire ${instances.length === 1 ? "it" : "them"} with the 0.25 kernel or delete the directory once ${instances.length === 1 ? "it is" : "they are"} stopped${instances.length ? ` (${instances.join(", ")})` : ""}` };
111
114
  }
115
+ /** The instance homes under the agents root `root` (a directory <root>/<agent>/instances/<name>
116
+ * whose instance.json records that name) that other users on the machine can read or enter (any
117
+ * group or other permission bit): one `home-readable` problem naming them, with the exact chmod,
118
+ * or null. A home holds identity keys, TASK.md and transcripts; spawn creates it 0700. Only
119
+ * reported: the operator decides. */
120
+ export function readableInstanceHomes(root) {
121
+ if (!root) return null;
122
+ const homes = [];
123
+ for (const agent of subdirs(root)) for (const name of subdirs(join(root, agent, "instances"))) {
124
+ const home = join(root, agent, "instances", name);
125
+ try {
126
+ if (JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.instance !== name) continue;
127
+ if (lstatSync(home).mode & 0o077) homes.push(home);
128
+ } catch { /* no readable instance.json, or gone meanwhile: not a home */ }
129
+ }
130
+ if (!homes.length) return null;
131
+ homes.sort();
132
+ const fix = `chmod 700 ${homes.map(shq).join(" ")}`;
133
+ return { code: "home-readable", homes, fix,
134
+ message: `${homes.length} instance home${homes.length === 1 ? " can" : "s can"} be read by other users on this machine (${homes.length === 1 ? "it holds" : "they hold"} identity keys, TASK.md and transcripts); fix: ${fix}` };
135
+ }
112
136
  /** The captured homes (the 0.24–0.25 captured/portable path, removed in 0.26) under the
113
137
  * agents root `root`: one `legacy-captured-home` problem naming them, or null. They have
114
138
  * no 0.26 runtime (start, inspect and in-home commands refuse them); retire still works. */
115
139
  export function legacyCapturedHomes(root) {
116
140
  if (!root) return null;
117
- const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
118
141
  const homes = [];
119
142
  for (const agent of subdirs(root)) for (const inst of subdirs(join(root, agent, "instances"))) {
120
143
  const home = join(root, agent, "instances", inst);
@@ -883,11 +906,21 @@ function readCatalogFile() {
883
906
  // catalog belongs to the kernel install.
884
907
  const override = !!process.env.OATS_PACKAGE_CATALOG;
885
908
  if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
886
- let doc;
887
909
  const text = readFileSync(file, "utf8");
888
910
  if (override) recordLocalInput(file, text);
911
+ return parsePackageCatalog(text, file);
912
+ }
913
+ /** A package catalog's text, parsed and checked as the kernel reads it (named `file` in errors):
914
+ * { packages, capabilities, file }, both maps null-prototype; invalid-source when it is broken. */
915
+ export function parsePackageCatalog(text, file) {
916
+ let doc;
889
917
  try { doc = JSON.parse(text); }
890
- catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
918
+ catch (e) {
919
+ // JSON.parse does not say where; the kernel's strict reader (same grammar) locates the error.
920
+ let line;
921
+ try { parseStrictJson(text, { maxBytes: 64 * 1024 * 1024 }); } catch (s) { if (Number.isInteger(s.provenance?.offset)) line = lineAt(text, s.provenance.offset); }
922
+ throw oatsError("invalid-source", `broken package catalog ${file}${line ? ` (line ${line})` : ""}: ${e.message}`);
923
+ }
891
924
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
892
925
  const out = { packages: Object.create(null), capabilities: Object.create(null), file };
893
926
  const packages = doc.packages;
@@ -2082,6 +2115,12 @@ export function upgradeHomeMeta(meta, home) {
2082
2115
  return { ...rest, ...(runtime !== undefined || meta.harness !== undefined ? { harness: meta.harness ?? runtime } : {}), ...(meta.launch !== undefined ? { launch: upgradeLaunchRecipe(meta.launch) } : {}) };
2083
2116
  }
2084
2117
  const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
2118
+ /** The fixed prompt a codex launch starts with: it names the task file, never its text (argv is
2119
+ * readable by every local user). Codex reads the file with a tool. */
2120
+ export const CODEX_TASK_PROMPT = "Read TASK.md in this directory first: it is your briefing and your task.";
2121
+ /** The prompt each harness gets, so the task's text never travels in argv: Claude Code inlines an
2122
+ * `@file` mention; codex is pointed at the file. pi takes `@TASK.md` among its own arguments. */
2123
+ const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT });
2085
2124
 
2086
2125
  /** The executable a launch uses: a configuration's declared one (a bare name
2087
2126
  * on PATH; a path against the deployment directory when relative) or the
@@ -2354,7 +2393,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2354
2393
  // The launch's environment, in prefix order: the instance, the capabilities' env, the
2355
2394
  // configuration's env (a reference by reference, never by value).
2356
2395
  const hookEnv = recipe.hooks?.env || {};
2357
- const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home], ["PI_AGENT_INSTANCE", instance], ["PI_AGENT_HOME", home]].map(([name, value]) => ({ name, value }));
2396
+ const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home]].map(([name, value]) => ({ name, value }));
2358
2397
  for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
2359
2398
  for (const name of Object.keys(recipe.env || {}).sort()) {
2360
2399
  const v = recipe.env[name];
@@ -2362,7 +2401,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2362
2401
  }
2363
2402
  let cmdline;
2364
2403
  if (harness === "claude") {
2365
- cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2404
+ cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.claude)}`;
2366
2405
  } else if (harness === "codex") {
2367
2406
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
2368
2407
  // Codex may run tool commands outside the session's process (its shared app-server daemon),
@@ -2370,7 +2409,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2370
2409
  // never goes on argv, and PATH is the execution's (CODEX_TOOL_PATH: the shim first).
2371
2410
  const toolEnvArgs = env.filter((e) => !e.reference && e.name !== "PATH")
2372
2411
  .map((e) => ` -c ${shq(`shell_environment_policy.set.${e.name}=${JSON.stringify(e.value)}`)}`).join("");
2373
- cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2412
+ cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.codex)}`;
2374
2413
  } else {
2375
2414
  // Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
2376
2415
  // .agents/skills up the tree, so the instance's copied capability skills are
@@ -2387,7 +2426,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2387
2426
  * with the home's kernel shim first on PATH (every launch runs through here). */
2388
2427
  function nativeRecordCommand(command, home, harness) {
2389
2428
  const { tokens, binary } = parseLaunchCommand(command);
2390
- const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2429
+ const args = tokens.slice(binary + 1).map(t => t.value ?? t.text);
2391
2430
  const id = prepareNativeStart(home, harness);
2392
2431
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2393
2432
  const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${executionArgv(tokens, binary, harness).join(" ")}`;
@@ -2561,7 +2600,7 @@ export function describeLaunchCommand(command) {
2561
2600
  * stay references); a command the parser refuses is withheld whole. */
2562
2601
  /** The identity environment every launch carries: public facts (instance
2563
2602
  * name, home path), shown in public renderings; everything else is withheld. */
2564
- export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
2603
+ export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME"]);
2565
2604
  export function redactLaunchCommand(command) {
2566
2605
  try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
2567
2606
  catch { return "<unparseable launch command withheld>"; }
@@ -2720,7 +2759,7 @@ function* spawnBody(root, agent, o = {}) {
2720
2759
  // decision: an attached agent shares its owner's work tree and is ALWAYS the
2721
2760
  // owner's child — relation flags that say anything else are contradictory and
2722
2761
  // rejected. Ambient env
2723
- // (OATS_INSTANCE/PI_AGENT_INSTANCE) is deliberately NOT consulted: any shell
2762
+ // (OATS_INSTANCE) is deliberately NOT consulted: any shell
2724
2763
  // opened inside an agent's tmux window inherits those vars, and env inheritance
2725
2764
  // is not evidence of intent — human spawns from such shells were misattributed
2726
2765
  // as instance-origin. Manual spawns land top-level unless a relation is
@@ -3102,7 +3141,8 @@ function* spawnBody(root, agent, o = {}) {
3102
3141
  // apply of the same decision, or anything else) got here first, and this one
3103
3142
  // has touched nothing.
3104
3143
  mkdirSync(dirname(home), { recursive: true });
3105
- try { mkdirSync(home); }
3144
+ // 0700: the home holds the instance's identity keys, TASK.md and transcripts.
3145
+ try { mkdirSync(home, { mode: 0o700 }); }
3106
3146
  catch (e) {
3107
3147
  if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
3108
3148
  throw e;
@@ -3606,7 +3646,7 @@ You are instance "${instance}" of agent "${agent.name}".
3606
3646
  - Home: ${home}
3607
3647
  - Work tree: ./work — ${workDesc}
3608
3648
  - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${harness === "codex" ? "\n## Harness notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
3609
- ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
3649
+ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`, { mode: 0o600 }); // human text: the owner's only
3610
3650
 
3611
3651
  // Launch command. Spawn IS session start: the recipe is persisted in
3612
3652
  // instance.json beside its rendering, which is executed in the instance's
@@ -4564,8 +4604,18 @@ export function inputInstanceSession(home, text) {
4564
4604
 
4565
4605
  // ---------------------------------------------------------------- session start
4566
4606
 
4567
- /** The exact prompt token spawn renders for claude and codex launches. */
4607
+ /** The prompt token of a recorded claude or codex command that hands the harness the task's own
4608
+ * text in argv, where any local user can read it: `"$(cat TASK.md)"`. It is parsed, and replaced
4609
+ * when the home starts (withSafeTaskPrompt). */
4568
4610
  const LAUNCH_PROMPT_TOKEN = '"$(cat TASK.md)"';
4611
+ /** A recorded command with its harness's safe task prompt: a `"$(cat TASK.md)"` token becomes
4612
+ * TASK_PROMPT[harness]. Applied when a home starts from its recorded command, which is saved so. */
4613
+ export function withSafeTaskPrompt(command, harness) {
4614
+ const { tokens } = parseLaunchCommand(command);
4615
+ if (!tokens.some((t) => t.kind === "prompt")) return command;
4616
+ if (!Object.hasOwn(TASK_PROMPT, harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `a ${harness} command never carried the task as "$(cat TASK.md)"; inspect the saved command in instance.json`);
4617
+ return renderLaunchCommand(tokens.map((t) => t.kind === "prompt" ? { kind: "word", value: TASK_PROMPT[harness], quoted: true, text: shq(TASK_PROMPT[harness]) } : t));
4618
+ }
4569
4619
 
4570
4620
  /** Tokenize a persisted OATS launch command. The grammar is exactly what
4571
4621
  * spawn renders: space-separated tokens that are env assignments NAME='v',
@@ -5004,6 +5054,8 @@ export function startInstanceSession(home, o = {}) {
5004
5054
  command = withLaunchModel(command, resolved);
5005
5055
  model = resolved; explicitModelFrom = "start";
5006
5056
  } else parseLaunchCommand(command);
5057
+ // A recorded command starts, and is saved, with its harness's safe task prompt.
5058
+ if (!launchPlan) command = withSafeTaskPrompt(command, harness);
5007
5059
  // References recorded for this home must resolve on this host on every
5008
5060
  // start path, and the source variables go to the pane, not the command.
5009
5061
  const recipeForEnv = launchPlan?.recipe || (meta.launch && typeof meta.launch === "object" ? meta.launch : null);
@@ -5098,7 +5150,7 @@ export function startInstanceSession(home, o = {}) {
5098
5150
  }
5099
5151
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5100
5152
  // Keep launch evidence until the command exits or the target disappears.
5101
- // A transient child (for example cat TASK.md) is not proof that startup
5153
+ // A transient child (for example the native-start recorder) is not proof that startup
5102
5154
  // has finished. A later start reconciles the receipt without a watcher.
5103
5155
  try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5104
5156
  catch (e) {
@@ -85,21 +85,43 @@ export function qualifiedSoulName(entry) {
85
85
  return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
86
86
  }
87
87
 
88
+ /** A soul name as an operator gives it: `[repoPart, soulPart]`, repoPart null for a bare name. */
89
+ function splitSoulName(name) {
90
+ return name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
91
+ }
92
+ /** Whether `name` names the member or package soul `{ name, repoKey }` / `{ name, package }`: a
93
+ * bare name, `<repo>/<soul>` with the member's key, a key suffix or its member name, or
94
+ * `<package>/<soul>` for a package soul. findSoulEntry's rule for each candidate. */
95
+ export function soulNameMatches(name, soul) {
96
+ const [repoPart, soulPart] = splitSoulName(name);
97
+ if (soulPart !== soul.name) return false;
98
+ if (!repoPart) return true;
99
+ if (typeof soul.package === "string") return repoPart === soul.package;
100
+ const key = soul.repoKey ?? "";
101
+ return key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
102
+ }
103
+ /** Whether `name` names the soul an instance home was spawned from (its instance.json `meta`): any name
104
+ * a spawn accepts for it, or the home's agent directory name. */
105
+ export function homeSoulMatches(name, meta) {
106
+ if (name === meta?.agent) return true;
107
+ const soul = meta?.workspace?.soul ?? {};
108
+ return soulNameMatches(name, soul.package && typeof soul.package === "object" ? { name: soul.name, package: soul.package.id } : { name: meta?.agent, repoKey: soul.repoKey });
109
+ }
110
+
88
111
  /** Find the soul named `name` in a discovery: confirmed members, external souls and package
89
112
  * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
90
113
  * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
91
114
  * or an external source, `<package>/<soul>` a package soul. */
92
115
  export function findSoulEntry(discovery, name) {
93
- const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
116
+ const [repoPart, soulPart] = splitSoulName(name);
94
117
  const hits = [];
95
118
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
96
119
  // workspace could not be read); resolveSoul admits exactly that case.
97
120
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
98
- const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
99
121
  for (const m of discovery.members || []) {
100
122
  if (!m.confirmed && m.key !== standaloneOwn) continue;
101
123
  for (const s of m.souls || []) {
102
- if (s.name !== soulPart || !memberMatches(m.key)) continue;
124
+ if (!soulNameMatches(name, { name: s.name, repoKey: m.key })) continue;
103
125
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
104
126
  }
105
127
  }
@@ -107,7 +129,7 @@ export function findSoulEntry(discovery, name) {
107
129
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
108
130
  }
109
131
  for (const s of discovery.packageSouls || []) {
110
- if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
132
+ if (soulNameMatches(name, { name: s.name, package: s.package })) hits.push({ ...s, external: false });
111
133
  }
112
134
  if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
113
135
  if (hits.length > 1) {
package/lib/remote.mjs CHANGED
@@ -4,7 +4,11 @@
4
4
  * APPROACH (one approach, used for every remote kind — local bare repos and
5
5
  * https/ssh remotes alike):
6
6
  * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
- * never a fetch when the caller already gave a full OID.
7
+ * never a fetch when the caller already gave a full OID. A HEAD observation
8
+ * speaks protocol v0 unless the operator pinned `protocol.version` (one round
9
+ * trip instead of v2's two; what is kept of the advertisement is bounded, and a
10
+ * remote over budget, or one that timed out under v0, is observed under v2:
11
+ * observeLive).
8
12
  * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
13
  * commit locally. `ensureCommit` does a shallow, partial
10
14
  * `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
@@ -168,6 +172,10 @@ export const GIT_FETCH_TIMEOUT_MS = 600_000;
168
172
  /** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
169
173
  * this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
170
174
  export const READ_REMOTE_BUDGET_MS = 12_000;
175
+ /** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
176
+ * reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
177
+ * and the remote observed under v2, its server-side filter, from then on (observeLive). */
178
+ export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
171
179
  /** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
172
180
  export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
173
181
  /** `--max-age` bounds (seconds). */
@@ -752,7 +760,7 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
752
760
 
753
761
  class ReadSession {
754
762
  constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
755
- cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
763
+ cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
756
764
  if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
757
765
  if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
758
766
  this.maxAge = maxAge;
@@ -764,6 +772,8 @@ class ReadSession {
764
772
  this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
765
773
  this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
766
774
  this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
775
+ this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
776
+ this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
767
777
  this.parsedLimits = parsedLimits; // tests inject small prune bounds
768
778
  this.observations = new Map(); // memo key → Promise<head observation>
769
779
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
@@ -853,7 +863,8 @@ class ReadSession {
853
863
 
854
864
  /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
855
865
  * milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
856
- * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
866
+ * `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
867
+ * `v0AdvertisementBudget`. */
857
868
  export function createReadSession(options = {}) { return new ReadSession(options); }
858
869
  /** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
859
870
  * by a caller that is still reading, so its shape only has to be a typed remote failure. */
@@ -1390,7 +1401,7 @@ function parseLsRemote(stdout) {
1390
1401
  }
1391
1402
 
1392
1403
  function resolveAt(parsed, at) {
1393
- if (at === undefined || at === null || at === "" || at === "HEAD") {
1404
+ if (isHeadAt(at)) {
1394
1405
  const oid = parsed.oids.get("HEAD");
1395
1406
  return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
1396
1407
  }
@@ -1434,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
1434
1445
 
1435
1446
  /** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
1436
1447
  function lsRemoteArgs(ref, at) {
1437
- const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
1448
+ const wantHead = isHeadAt(at);
1438
1449
  if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
1439
1450
  const args = ["ls-remote", "--symref", ref.url];
1440
1451
  if (wantHead) args.push("HEAD");
@@ -1518,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
1518
1529
  } finally { session.observeDone(); }
1519
1530
  }
1520
1531
 
1532
+ /** Whether `at` asks for the remote's default branch (a HEAD observation). */
1533
+ const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
1534
+
1535
+ /** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
1536
+ * `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
1537
+ * once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
1538
+ * included) → true: today's argv, never an error. */
1539
+ const protocolPins = new WeakMap();
1540
+ function protocolPinned(exec, session) {
1541
+ const memo = session?.protocolPins ?? protocolPins;
1542
+ let pinned = memo.get(exec);
1543
+ if (!pinned) {
1544
+ pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
1545
+ .then(() => true, (error) => error?.code !== 1);
1546
+ memo.set(exec, pinned);
1547
+ }
1548
+ return pinned;
1549
+ }
1550
+
1551
+ /** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
1552
+ * beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
1553
+ * root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
1554
+ * good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
1555
+ * file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
1556
+ * Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
1557
+ * that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
1558
+ const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
1559
+ const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
1560
+ function lsRemoteV2Recorded(root, ref, now) {
1561
+ const record = (reason) => {
1562
+ const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
1563
+ return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
1564
+ };
1565
+ if (record("overflow")) return true;
1566
+ const rec = record("timeout");
1567
+ const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
1568
+ return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
1569
+ }
1570
+ function recordLsRemoteV2(root, ref, reason, now) {
1571
+ writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
1572
+ }
1573
+
1574
+ /** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
1575
+ * such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
1576
+ const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
1577
+
1578
+ /**
1579
+ * `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
1580
+ * operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
1581
+ * advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
1582
+ * filtered answer gives, HEAD's symref included (v0's symref capability).
1583
+ * - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
1584
+ * advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
1585
+ * recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
1586
+ * - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
1587
+ * - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
1588
+ * and said once if the retry succeeds.
1589
+ * `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
1590
+ * and branches always use it.
1591
+ */
1521
1592
  async function observeLive(ref, args, at, options) {
1522
1593
  const exec = options.exec ?? runGit;
1523
- const signal = sessionOf(options)?.signal;
1524
- let out;
1525
- try { out = await sessionExec(exec, sessionOf(options), args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
1526
- catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1594
+ const session = sessionOf(options);
1595
+ const signal = session?.signal;
1596
+ // Every git call takes what is left of the session's deadline, if it has one (sessionExec).
1597
+ const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
1598
+ const root = cacheRootOf(options);
1599
+ const now = () => (session ? session.now() : Date.now());
1600
+ const url = redactUrl(ref.url);
1601
+ const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
1602
+ let out = null, retried = null;
1603
+ const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
1604
+ // A command given up while its protocol was asked starts no ls-remote.
1605
+ if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
1606
+ if (v0) {
1607
+ const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
1608
+ // A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
1609
+ // which says nothing about the remote: its timeout is not recorded.
1610
+ const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
1611
+ try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
1612
+ catch (error) {
1613
+ if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
1614
+ recordLsRemoteV2(root, ref, "overflow", now());
1615
+ say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
1616
+ } else {
1617
+ const reason = classifyRemoteFailure(error);
1618
+ const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
1619
+ if (!aborted && reason === "timeout" && !cut) {
1620
+ recordLsRemoteV2(root, ref, "timeout", now());
1621
+ say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
1622
+ }
1623
+ if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
1624
+ retried = reason;
1625
+ }
1626
+ }
1627
+ }
1628
+ if (!out) {
1629
+ try { out = await run(args); }
1630
+ catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
1631
+ }
1527
1632
  const parsed = parseLsRemote(out.stdout);
1528
1633
  const hit = resolveAt(parsed, at);
1529
1634
  if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1530
1635
  if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
1531
1636
  const { commit } = await ensureCommit(ref, hit.commit, options);
1637
+ if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
1532
1638
  return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
1533
1639
  }
1640
+ const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
1534
1641
 
1535
1642
  // ---------------------------------------------------------------------------
1536
1643
  // tree reading
package/lib/workspace.mjs CHANGED
@@ -334,8 +334,9 @@ function schemaHint(kind, value) {
334
334
  }
335
335
  return "";
336
336
  }
337
- /** Parse + validate a declaration file → { value } | { problems }. Never throws. */
338
- function readDeclaration(kind, bytes, origin, validateOptions) {
337
+ /** Parse + validate a declaration file (`kind` workspace | membership | soul | local) → { value } |
338
+ * { problems }. Never throws. The reader `oats sync` uses, exported for the repository's validate. */
339
+ export function readDeclaration(kind, bytes, origin, validateOptions) {
339
340
  const decoded = decodeDocument(bytes, origin);
340
341
  if (decoded.problems) return decoded;
341
342
  const problems = FILE_KINDS[kind].validate(decoded.value, validateOptions);