@awebai/oats 0.24.12 → 0.25.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.
Files changed (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
package/lib/remote.mjs ADDED
@@ -0,0 +1,639 @@
1
+ /** lib/remote.mjs — observe Git remotes in the operator's own access context
2
+ * (workspace model v2, module contract §1).
3
+ *
4
+ * APPROACH (one approach, used for every remote kind — local bare repos and
5
+ * https/ssh remotes alike):
6
+ * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
+ * never a fetch when the caller already gave a full OID.
8
+ * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
+ * commit's objects locally. `ensureCommit` does a shallow
10
+ * `git fetch --depth 1 --no-tags <url> <oid>` into a content-addressed BARE
11
+ * cache repo under `<cacheRoot>/<sha256(key)>/` and then pins the commit with
12
+ * `refs/oats/commits/<oid>` so `gc` cannot prune it. The pin doubles as the
13
+ * "already fetched" marker: a pinned commit is never fetched again.
14
+ * 3. Reads are then local plumbing: `git ls-tree -r -t -l -z` for listing and
15
+ * `git cat-file blob` for bytes. `git archive --remote` is NOT used: GitHub and
16
+ * most https hosts refuse it, and per-entry plumbing lets us inspect every
17
+ * mode (symlink / submodule / oversize) BEFORE anything touches the disk.
18
+ *
19
+ * DEVIATION FROM THE CONTRACT (named on purpose, not silently resolved): the
20
+ * contract says "shallow git fetch --depth 1 --filter=blob:none". A blob-less
21
+ * partial fetch would make every later `cat-file` a lazy per-blob network
22
+ * round-trip through the promisor machinery, and topping a filtered commit up
23
+ * to a full one afterwards requires `--refetch` semantics that vary by server.
24
+ * We fetch depth-1 WITHOUT a blob filter: one round-trip per commit, blobs
25
+ * present, correct on every server that allows fetching an advertised OID.
26
+ *
27
+ * The cache is invisible plumbing: it may be wiped at any time (a wiped cache
28
+ * simply re-fetches), and nothing outside this module references it.
29
+ *
30
+ * Nothing here ever prompts: GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=/usr/bin/false,
31
+ * ssh ALWAYS in BatchMode — `-o BatchMode=yes` is appended to the operator's own
32
+ * GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call.
33
+ *
34
+ * TREE SAFETY: every entry name git reports is validated BEFORE anything touches
35
+ * the disk. A component that is empty, `.`, `..`, `.git` (any case) or contains
36
+ * `\` / NUL is E_REMOTE_TREE_UNSAFE { why: "path" } — a crafted tree object can
37
+ * carry such names (the transport does not fsck them), and `join()` would
38
+ * happily normalize `../../x` out of the staging directory. Entries that would
39
+ * collide on a case-/normalization-insensitive filesystem (README.md vs
40
+ * readme.md, NFC vs NFD) are E_REMOTE_TREE_UNSAFE { why: "collision" }.
41
+ *
42
+ * CONCURRENCY: per-key operations on one cache repo are serialized in-process
43
+ * (two observes of the same remote never race `git init` or `fetch`), and a
44
+ * fetch that loses an on-disk `.lock` race to another process is retried.
45
+ *
46
+ * CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
47
+ * pruned object store is detected (`cat-file -e <oid>^{commit}`) and refetched;
48
+ * the pin never turns a stale cache into a claim about the remote. The object
49
+ * must be a COMMIT: a tag or `at` naming a tree/blob is E_REMOTE_UNREADABLE.
50
+ *
51
+ * CONTENT DIGEST (canonical framing, shared by fetchRemoteTree and contentDigest):
52
+ * sha256 over the concatenation, for every REGULAR FILE sorted by relpath
53
+ * (byte-wise UTF-8 order, "/" separated, relative to the tree root), of
54
+ * "F" NUL relpath NUL mode-octal NUL size-decimal NUL bytes NUL
55
+ * where mode-octal is the GIT-NORMALIZED mode: "755" when the owner-exec bit
56
+ * is set, else "644" (Git stores only 100644/100755, so a local checkout under
57
+ * any umask digests identically to the fetched tree). Empty directories do not
58
+ * enter identity. Result: "sha256-<hex>". Empty tree → sha256 of the empty input.
59
+ */
60
+ import { execFile, execFileSync } from "node:child_process";
61
+ import { createHash } from "node:crypto";
62
+ import {
63
+ closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
64
+ writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
65
+ import { homedir } from "node:os";
66
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
67
+ import { fileURLToPath } from "node:url";
68
+ import { oatsError } from "./errors.mjs";
69
+
70
+ export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
71
+ export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
72
+ export const GIT_TIMEOUT_MS = 30_000;
73
+ const OID_RE = /^[0-9a-f]{40}$/;
74
+ const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
75
+ /** A ref name we are willing to hand to `git ls-remote` as a pattern: never a leading
76
+ * dash (option injection), no whitespace/control chars, no revision syntax (`^{`, `@{`, `~`,
77
+ * `:`), no `..`, no glob chars, no `\`, no `.lock` component, no leading/trailing `/`. */
78
+ const AT_BAD_RE = /^[-/]|\/$|\.\.|[\s~^:?*[\\\x00-\x1f\x7f]|@\{|\/\.|^\.|\.lock(?:\/|$)|\/\//;
79
+ /** Tree entry-name components that must never reach the filesystem. */
80
+ const BAD_COMPONENT_RE = /^(?:|\.|\.\.|\.git)$/i;
81
+
82
+ /** oatsError with the details reachable as BOTH e.provenance (today's field) and e.details. */
83
+ function fail(code, message, details) {
84
+ const e = oatsError(code, message, details);
85
+ if (details) e.details = details;
86
+ return e;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // parseRepoRef
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function repoPathSegments(rawPath, text) {
94
+ const cleaned = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
95
+ const parts = cleaned.split("/");
96
+ if (parts.length < 2 || parts.some((p) => !SEGMENT_RE.test(p) || p === "." || p === "..")) {
97
+ throw fail("E_REPO_REF", `repository reference has an invalid path: ${text}`, { ref: text, path: rawPath });
98
+ }
99
+ return parts.join("/");
100
+ }
101
+
102
+ function hostedRef(host, rawPath, text) {
103
+ const h = host.toLowerCase();
104
+ if (!/^[a-z0-9.-]+$/.test(h)) throw fail("E_REPO_REF", `repository reference has an invalid host: ${text}`, { ref: text, host });
105
+ const path = repoPathSegments(rawPath, text);
106
+ return Object.freeze({ host: h, path, url: `https://${h}/${path}.git`, key: `${h}/${path}` });
107
+ }
108
+
109
+ function localRef(absPath) {
110
+ const normalized = resolve(absPath).replace(/[\\/]+$/, "") || sep;
111
+ return Object.freeze({ host: "local", path: normalized, url: normalized, key: `local/${normalized}` });
112
+ }
113
+
114
+ /**
115
+ * "git:github.com/org/repo(.git)" | "https://github.com/org/repo(.git)" | "git@github.com:org/repo(.git)"
116
+ * | "/abs/path/to/bare.git" | "file:///abs/path" → { host, path, url, key } | throws E_REPO_REF.
117
+ * Already-parsed refs (objects with key+url) pass through once re-validated: the
118
+ * object's `url` must itself parse to the same `key` (no smuggled `ext::` urls or `../` keys).
119
+ */
120
+ export function parseRepoRef(text) {
121
+ if (text && typeof text === "object" && typeof text.key === "string" && typeof text.url === "string") {
122
+ const again = parseRepoRef(text.url);
123
+ if (again.key !== text.key) throw fail("E_REPO_REF", `repository reference object is inconsistent: key ${text.key} does not match url ${text.url}`, { ref: text.key, url: text.url });
124
+ return text;
125
+ }
126
+ if (typeof text !== "string" || !text.trim()) throw fail("E_REPO_REF", "repository reference must be a non-empty string", { ref: text });
127
+ const ref = text.trim();
128
+ let m;
129
+ if ((m = /^git:([^/:@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
130
+ if ((m = /^https?:\/\/([^/@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
131
+ if ((m = /^git@([^:/\s]+):(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
132
+ if (/^file:\/\//.test(ref)) {
133
+ try { return localRef(fileURLToPath(ref)); } catch { throw fail("E_REPO_REF", `invalid file URL: ${ref}`, { ref }); }
134
+ }
135
+ if (isAbsolute(ref)) return localRef(ref);
136
+ throw fail("E_REPO_REF", `unrecognized repository reference: ${ref}`, { ref });
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // git plumbing
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** The operator's own ssh command (GIT_SSH_COMMAND, else core.sshCommand, else `ssh`),
144
+ * ALWAYS with `-o BatchMode=yes` appended so ssh can never prompt for a passphrase or
145
+ * host key. Resolved once per process; `-o` after the operator's flags is valid for ssh
146
+ * and for every ssh-compatible wrapper that forwards its argv. */
147
+ let sshCommandCache;
148
+ export function sshCommand() {
149
+ if (sshCommandCache !== undefined) return sshCommandCache;
150
+ let base = process.env.GIT_SSH_COMMAND?.trim();
151
+ if (!base) {
152
+ try { base = execFileSync("git", ["config", "--get", "core.sshCommand"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { base = ""; }
153
+ }
154
+ if (!base) base = "ssh";
155
+ sshCommandCache = /(?:^|\s)-o\s*BatchMode=yes(?:\s|$)/i.test(base) ? base : `${base} -o BatchMode=yes`;
156
+ return sshCommandCache;
157
+ }
158
+
159
+ function gitEnv() {
160
+ return {
161
+ ...process.env,
162
+ GIT_TERMINAL_PROMPT: "0",
163
+ GIT_ASKPASS: "/usr/bin/false",
164
+ GIT_SSH_COMMAND: sshCommand(),
165
+ GIT_LITERAL_PATHSPECS: "1",
166
+ };
167
+ }
168
+
169
+ /** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
170
+ * rejects with { code, signal, killed, stderr, stdout }. Injectable via options.exec. */
171
+ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS } = {}) {
172
+ return new Promise((resolvePromise, reject) => {
173
+ execFile("git", args, { cwd, env: gitEnv(), timeout, maxBuffer, shell: false, encoding: "buffer", killSignal: "SIGKILL" },
174
+ (error, stdout, stderr) => {
175
+ if (error) {
176
+ error.stdout = stdout; error.stderr = stderr;
177
+ error.timedOut = error.killed === true || error.signal === "SIGKILL";
178
+ reject(error);
179
+ } else resolvePromise({ stdout, stderr });
180
+ });
181
+ });
182
+ }
183
+
184
+ function stderrText(error) {
185
+ const s = error?.stderr;
186
+ return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
187
+ }
188
+
189
+ /** Classify a failed network git call into the contract's four reasons. */
190
+ export function classifyRemoteFailure(error) {
191
+ if (error?.timedOut || error?.signal === "SIGKILL" || error?.signal === "SIGTERM") return "timeout";
192
+ const text = stderrText(error).toLowerCase();
193
+ if (/authentication failed|could not read username|could not read password|permission denied|publickey|403|forbidden|terminal prompts disabled|invalid username or password|access denied|unauthori[sz]ed/.test(text)) return "auth";
194
+ if (/repository not found|repository '.*' not found|not found|does not appear to be a git repository|no such file or directory|not our ref|unadvertised object|couldn't find remote ref|remote ref .* not found|not a valid object name|not a tree object|bad object|is not a valid revision|no such ref/.test(text)) return "not-found";
195
+ if (/could not resolve host|connection refused|connection timed out|network is unreachable|unable to access|early eof|remote end hung up|connection reset|ssl|tls|unable to connect/.test(text)) return "network";
196
+ return "network";
197
+ }
198
+
199
+ function unreadable(ref, error, extra = {}) {
200
+ const reason = classifyRemoteFailure(error);
201
+ return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})`, { url: ref.url, key: ref.key, reason, ...extra });
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // cache
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function defaultCacheRoot() { return join(homedir(), ".cache", "oats", "remotes"); }
209
+
210
+ /** Per-cache-dir in-process mutex: operations on one bare cache repo run one at a time. */
211
+ const cacheLocks = new Map();
212
+ async function withCacheLock(dir, fn) {
213
+ const previous = cacheLocks.get(dir) ?? Promise.resolve();
214
+ let release;
215
+ const mine = new Promise((r) => { release = r; });
216
+ cacheLocks.set(dir, previous.then(() => mine));
217
+ await previous;
218
+ try { return await fn(); } finally {
219
+ release();
220
+ if (cacheLocks.get(dir) === mine) cacheLocks.delete(dir);
221
+ }
222
+ }
223
+
224
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
225
+ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running/i.test(stderrText(error));
226
+
227
+ async function cacheRepo(ref, options) {
228
+ const exec = options.exec ?? runGit;
229
+ const root = options.cacheDir ?? defaultCacheRoot();
230
+ const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
231
+ if (!existsSync(join(dir, "HEAD"))) {
232
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
233
+ try { await exec(["init", "-q", "--bare", dir]); }
234
+ catch (error) {
235
+ // Another process may have won the init race; a usable repo is all we need.
236
+ if (!existsSync(join(dir, "HEAD"))) throw unreadable(ref, error, { cacheDir: dir, stage: "init" });
237
+ }
238
+ try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: ref.url }, null, 2) + "\n", { flag: "wx" }); } catch {}
239
+ }
240
+ const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...opts });
241
+ return { dir, exec, local };
242
+ }
243
+
244
+ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
245
+
246
+ /** Is <oid> present in the cache AND a commit? (`cat-file -e <oid>^{commit}` fails on a
247
+ * missing object, a pruned store, or a tag/tree/blob.) */
248
+ async function hasCommit(repo, oid) {
249
+ return repo.local(["cat-file", "-e", `${oid}^{commit}`]).then(() => true, () => false);
250
+ }
251
+
252
+ async function objectType(repo, oid) {
253
+ try { return (await repo.local(["cat-file", "-t", oid])).stdout.toString("utf8").trim(); } catch { return null; }
254
+ }
255
+
256
+ /** Ensure <oid> (a full COMMIT OID) and all its trees/blobs are present in the cache. */
257
+ async function ensureCommit(ref, oid, options) {
258
+ const root = options.cacheDir ?? defaultCacheRoot();
259
+ const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
260
+ return withCacheLock(dir, async () => {
261
+ const repo = await cacheRepo(ref, options);
262
+ if (await hasCommit(repo, oid)) return repo; // pinned AND present AND a commit
263
+ let lastError;
264
+ for (let attempt = 0; attempt < 3; attempt++) {
265
+ try {
266
+ await repo.local(["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ref.url, oid]);
267
+ lastError = null;
268
+ break;
269
+ } catch (error) {
270
+ lastError = error;
271
+ if (!isLockRace(error) || error.timedOut) break;
272
+ await sleep(100 * (attempt + 1) ** 2);
273
+ }
274
+ }
275
+ if (lastError) throw unreadable(ref, lastError, { commit: oid });
276
+ if (!(await hasCommit(repo, oid))) {
277
+ const type = await objectType(repo, oid);
278
+ throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
279
+ }
280
+ await repo.local(["update-ref", pinRef(oid), oid]);
281
+ return repo;
282
+ });
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // observeRemote
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function requireCommit(commit) {
290
+ if (typeof commit !== "string" || !OID_RE.test(commit)) throw fail("E_REPO_REF", "commit must be a full 40-hex OID", { commit });
291
+ return commit;
292
+ }
293
+
294
+ function parseLsRemote(stdout) {
295
+ const lines = stdout.toString("utf8").split("\n").filter(Boolean);
296
+ const symrefs = new Map(), oids = new Map();
297
+ for (const line of lines) {
298
+ const [left, right] = line.split("\t");
299
+ if (left.startsWith("ref: ")) symrefs.set(right, left.slice(5));
300
+ else oids.set(right, left);
301
+ }
302
+ return { symrefs, oids };
303
+ }
304
+
305
+ function resolveAt(parsed, at) {
306
+ if (at === undefined || at === null || at === "" || at === "HEAD") {
307
+ const oid = parsed.oids.get("HEAD");
308
+ return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
309
+ }
310
+ const candidates = [`refs/tags/${at}^{}`, `refs/tags/${at}`, `refs/heads/${at}`, at, `${at}^{}`];
311
+ for (const name of candidates) {
312
+ const oid = parsed.oids.get(name);
313
+ if (oid) {
314
+ const bare = name.replace(/\^\{\}$/, "");
315
+ return { commit: oid, ref: bare.startsWith("refs/") ? bare : null };
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /**
322
+ * at: undefined → remote default branch (ls-remote --symref HEAD); a full OID; or a tag/branch name.
323
+ * → { key, url, commit, ref, observedAt } | throws E_REMOTE_UNREADABLE { url, reason }.
324
+ * Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
325
+ */
326
+ export async function observeRemote(refText, { at, ...options } = {}) {
327
+ const ref = parseRepoRef(refText);
328
+ const exec = options.exec ?? runGit;
329
+ if (at !== undefined && at !== null && typeof at !== "string") throw fail("E_REPO_REF", "at must be a string (full OID, tag or branch name)", { at });
330
+ if (typeof at === "string" && OID_RE.test(at)) {
331
+ await ensureCommit(ref, at, options);
332
+ return { key: ref.key, url: ref.url, commit: at, ref: null, observedAt: new Date().toISOString() };
333
+ }
334
+ const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
335
+ 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 });
336
+ const args = ["ls-remote", "--symref", ref.url];
337
+ if (wantHead) args.push("HEAD");
338
+ else args.push(`refs/tags/${at}`, `refs/tags/${at}^{}`, `refs/heads/${at}`, at);
339
+ let out;
340
+ try { out = await exec(args, { timeout: GIT_TIMEOUT_MS }); }
341
+ catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
342
+ const parsed = parseLsRemote(out.stdout);
343
+ const hit = resolveAt(parsed, at);
344
+ 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 });
345
+ 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 });
346
+ await ensureCommit(ref, hit.commit, options);
347
+ return { key: ref.key, url: ref.url, commit: hit.commit, ref: hit.ref, observedAt: new Date().toISOString() };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // tree reading
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function normalizeTreePath(path, { allowRoot }) {
355
+ if (path === undefined || path === null || path === "" || path === ".") {
356
+ if (allowRoot) return "";
357
+ throw fail("E_REPO_REF", "path must name a file, not the tree root", { path });
358
+ }
359
+ if (typeof path !== "string") throw fail("E_REPO_REF", "path must be a string", { path });
360
+ const parts = path.replace(/\\/g, "/").split("/").filter((p) => p !== "" && p !== ".");
361
+ if (parts.length === 0) { if (allowRoot) return ""; throw fail("E_REPO_REF", "path must name a file", { path }); }
362
+ if (parts.some((p) => p === "..")) throw fail("E_REPO_REF", "path must be relative and must not escape the tree", { path });
363
+ return parts.join("/");
364
+ }
365
+
366
+ /** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. */
367
+ function parseLsTree(stdout) {
368
+ const entries = [];
369
+ for (const record of stdout.toString("utf8").split("\0")) {
370
+ if (!record) continue;
371
+ const tab = record.indexOf("\t");
372
+ const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
373
+ const [mode, type, oid, size] = meta;
374
+ entries.push({ mode, type, oid, size: size === "-" ? null : Number(size), path });
375
+ }
376
+ return entries;
377
+ }
378
+
379
+ /** Refuse any entry whose name could escape or subvert a checkout: a component that is
380
+ * empty, `.`, `..`, `.git` (any case), or a name containing `\` or NUL. Git's transport does
381
+ * not fsck tree entry names, so a hostile remote can serve them. */
382
+ function assertSafeEntryPath(entryPath, ref, commit, shown = entryPath) {
383
+ const bad = /[\\\x00]/.test(entryPath) || entryPath.split("/").some((c) => BAD_COMPONENT_RE.test(c));
384
+ if (bad) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has an unsafe entry name`, { path: shown, why: "path", key: ref.key, commit });
385
+ }
386
+
387
+ /** Entries that would collide on a case- or normalization-insensitive filesystem (APFS, NTFS). */
388
+ function assertNoCollisions(entries, ref, commit, prefix) {
389
+ const seen = new Map();
390
+ for (const e of entries) {
391
+ const folded = e.path.normalize("NFC").toLowerCase();
392
+ const other = seen.get(folded);
393
+ if (other !== undefined && other !== e.path) {
394
+ const shown = prefix ? `${prefix}/${e.path}` : e.path;
395
+ throw fail("E_REMOTE_TREE_UNSAFE", `${shown} collides with ${prefix ? `${prefix}/${other}` : other} on a case-insensitive filesystem`, { path: shown, why: "collision", other: prefix ? `${prefix}/${other}` : other, key: ref.key, commit });
396
+ }
397
+ seen.set(folded, e.path);
398
+ }
399
+ }
400
+
401
+ /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree. */
402
+ async function lsTree(repo, spec, { flags = [], path } = {}) {
403
+ try {
404
+ const args = ["ls-tree", "-l", "-z", ...flags, spec];
405
+ if (path !== undefined) args.push("--", path);
406
+ const out = await repo.local(args, { maxBuffer: 64 * 1024 * 1024 });
407
+ return parseLsTree(out.stdout);
408
+ } catch (error) {
409
+ const text = stderrText(error).toLowerCase();
410
+ if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
411
+ throw error;
412
+ }
413
+ }
414
+
415
+ /** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
416
+ async function entryAt(repo, commit, path) {
417
+ const entries = await lsTree(repo, commit, { path });
418
+ if (!entries) return null;
419
+ return entries.find((e) => e.path === path) ?? null;
420
+ }
421
+
422
+ /**
423
+ * → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
424
+ * A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
425
+ */
426
+ export async function readRemoteFile(refText, commit, path, options = {}) {
427
+ const ref = parseRepoRef(refText);
428
+ requireCommit(commit);
429
+ const rel = normalizeTreePath(path, { allowRoot: false });
430
+ const repo = await ensureCommit(ref, commit, options);
431
+ const entry = await entryAt(repo, commit, rel);
432
+ if (!entry || entry.type !== "blob") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a file in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
433
+ if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
434
+ if (entry.size > FILE_BUDGET) throw fail("E_REMOTE_FILE_OVERSIZE", `${rel} is ${entry.size} bytes (budget ${FILE_BUDGET})`, { path: rel, size: entry.size, budget: FILE_BUDGET, key: ref.key, commit });
435
+ let out;
436
+ try { out = await repo.local(["cat-file", "blob", entry.oid], { maxBuffer: FILE_BUDGET + 1024 }); }
437
+ catch (error) { throw unreadable(ref, error, { commit, path: rel }); }
438
+ return { bytes: out.stdout, size: out.stdout.length };
439
+ }
440
+
441
+ /**
442
+ * → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
443
+ * Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
444
+ */
445
+ export async function listRemoteTree(refText, commit, dir, { depth = 2, ...options } = {}) {
446
+ const ref = parseRepoRef(refText);
447
+ requireCommit(commit);
448
+ if (!Number.isInteger(depth) || depth < 1) throw fail("E_REPO_REF", "depth must be a positive integer", { depth });
449
+ const rel = normalizeTreePath(dir, { allowRoot: true });
450
+ const repo = await ensureCommit(ref, commit, options);
451
+ const spec = rel ? `${commit}:${rel}` : commit;
452
+ if (rel) {
453
+ const entry = await entryAt(repo, commit, rel);
454
+ if (!entry || entry.type !== "tree") return [];
455
+ }
456
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
457
+ if (!entries) return [];
458
+ const result = [];
459
+ for (const e of entries) {
460
+ assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
461
+ if (e.path.split("/").length > depth) continue;
462
+ if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
463
+ const type = e.mode === "120000" ? "symlink" : e.type;
464
+ const row = { path: e.path, type };
465
+ if (e.type === "blob") row.size = e.size;
466
+ result.push(row);
467
+ }
468
+ result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
469
+ return result;
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // digest
474
+ // ---------------------------------------------------------------------------
475
+
476
+ const NUL = Buffer.from([0]);
477
+ const gitMode = (mode) => ((mode & 0o100) ? "755" : "644");
478
+ const byPath = (a, b) => Buffer.compare(Buffer.from(a.path, "utf8"), Buffer.from(b.path, "utf8"));
479
+
480
+ function createDigest() {
481
+ const hash = createHash("sha256");
482
+ let files = 0, bytes = 0;
483
+ return {
484
+ add(relpath, mode, content) {
485
+ hash.update("F"); hash.update(NUL);
486
+ hash.update(Buffer.from(relpath, "utf8")); hash.update(NUL);
487
+ hash.update(gitMode(mode)); hash.update(NUL);
488
+ hash.update(String(content.length)); hash.update(NUL);
489
+ hash.update(content); hash.update(NUL);
490
+ files += 1; bytes += content.length;
491
+ },
492
+ finish() { return { files, bytes, digest: `sha256-${hash.digest("hex")}` }; },
493
+ };
494
+ }
495
+
496
+ /** Same digest as fetchRemoteTree, computed over a local directory. Symlinks and
497
+ * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING. */
498
+ function posixNormalize(p) {
499
+ const out = [];
500
+ for (const seg of p.split("/")) { if (!seg || seg === ".") continue; if (seg === "..") { if (out.length && out.at(-1) !== "..") out.pop(); else out.push(".."); } else out.push(seg); }
501
+ return out.join("/");
502
+ }
503
+ /**
504
+ * The ONE symlink an OATS tree may carry (decision 13): a `CLAUDE.md` whose target
505
+ * is `AGENTS.md` beside it — the Claude-runtime alias of the canonical
506
+ * instructions. Souls carry it at their root; a capability's agents/<name>/ dirs
507
+ * carry it too. Anything else stays refused. The rule is on the PATH (any depth,
508
+ * basename CLAUDE.md); fetchRemoteTree still enforces the target constraint
509
+ * (relative, non-escaping) and contentDigest digests it as `symlink:<target>`.
510
+ */
511
+ export const OATS_ALIAS_SYMLINK = (relPath) => typeof relPath === "string" && (relPath === "CLAUDE.md" || relPath.endsWith("/CLAUDE.md"));
512
+
513
+ export function contentDigest(dir, { allowSymlinks = null } = {}) {
514
+ if (typeof dir !== "string" || !isAbsolute(dir)) throw fail("E_REPO_REF", "contentDigest requires an absolute directory path", { path: dir });
515
+ let st;
516
+ try { st = lstatSync(dir); } catch { throw fail("E_REMOTE_PATH_MISSING", `${dir} does not exist`, { path: dir }); }
517
+ if (!st.isDirectory()) throw fail("E_REMOTE_PATH_MISSING", `${dir} is not a directory`, { path: dir });
518
+ const files = [];
519
+ const walk = (abs, rel) => {
520
+ for (const name of readdirSync(abs)) {
521
+ if (!rel && name === ".git") continue; // a local checkout's metadata is not content
522
+ const p = join(abs, name), r = rel ? `${rel}/${name}` : name, s = lstatSync(p);
523
+ if (s.isSymbolicLink()) {
524
+ // Refused by default (contract §1). The same narrow opt-in fetchRemoteTree
525
+ // honours (a relative, non-escaping alias such as CLAUDE.md → AGENTS.md)
526
+ // digests as `symlink:<target>` so a fetched tree and its digest agree.
527
+ if (typeof allowSymlinks !== "function" || !allowSymlinks(r)) throw fail("E_REMOTE_TREE_UNSAFE", `${r} is a symlink`, { path: r, why: "symlink" });
528
+ files.push({ path: r, mode: 0o777, bytes: Buffer.from(`symlink:${readlinkSync(p)}`) });
529
+ continue;
530
+ }
531
+ if (s.isDirectory()) walk(p, r);
532
+ else if (s.isFile()) files.push({ path: r, mode: s.mode, abs: p });
533
+ else throw fail("E_REMOTE_TREE_UNSAFE", `${r} is not a regular file`, { path: r, why: "device" });
534
+ }
535
+ };
536
+ walk(dir, "");
537
+ files.sort(byPath);
538
+ const d = createDigest();
539
+ for (const f of files) d.add(f.path, f.mode, f.bytes ?? readFileSync(f.abs));
540
+ return d.finish().digest;
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // fetchRemoteTree
545
+ // ---------------------------------------------------------------------------
546
+
547
+ /**
548
+ * Copies the subtree <dir> of <commit> into destDir (created; must not exist).
549
+ * Regular files and dirs only: symlinks/submodules → E_REMOTE_TREE_UNSAFE { path, why }, total > 64 MiB → why "oversize".
550
+ * Missing <dir> → E_REMOTE_PATH_MISSING. → { files, bytes, digest }. Atomic: staging dir + rename, nothing left on failure.
551
+ */
552
+ export async function fetchRemoteTree(refText, commit, dir, destDir, options = {}) {
553
+ const ref = parseRepoRef(refText);
554
+ requireCommit(commit);
555
+ const rel = normalizeTreePath(dir, { allowRoot: true });
556
+ if (typeof destDir !== "string" || !isAbsolute(destDir)) throw fail("E_REPO_REF", "destDir must be an absolute path", { destDir });
557
+ const dest = resolve(destDir);
558
+ let destStat = null;
559
+ try { destStat = lstatSync(dest); } catch {}
560
+ if (destStat) throw fail("E_REMOTE_TREE_UNSAFE", `${dest} already exists${destStat.isSymbolicLink() ? " (a symlink)" : ""}`, { path: dest, why: "exists" });
561
+ const repo = await ensureCommit(ref, commit, options);
562
+ const spec = rel ? `${commit}:${rel}` : commit;
563
+ if (rel) {
564
+ const entry = await entryAt(repo, commit, rel);
565
+ if (!entry || entry.type !== "tree") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a directory in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
566
+ }
567
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
568
+ if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
569
+ // Inspect everything BEFORE writing anything: names, modes, types, sizes, collisions.
570
+ let total = 0;
571
+ const blobs = [], trees = [], links = [];
572
+ // `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
573
+ // and stays inside the fetched subtree may be materialized as a symlink — the
574
+ // one legitimate case is a soul's `CLAUDE.md → AGENTS.md` alias. Anything else
575
+ // (absolute, escaping, or not allowed by the predicate) is refused as before.
576
+ const allowSymlinks = typeof options.allowSymlinks === "function" ? options.allowSymlinks : null;
577
+ for (const e of entries) {
578
+ const shown = rel ? `${rel}/${e.path}` : e.path;
579
+ assertSafeEntryPath(e.path, ref, commit, shown);
580
+ if (e.mode === "120000") {
581
+ if (!allowSymlinks || !allowSymlinks(e.path)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a symlink`, { path: shown, why: "symlink", key: ref.key, commit });
582
+ links.push(e); continue;
583
+ }
584
+ if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
585
+ if (e.type === "tree") { trees.push(e); continue; }
586
+ if (e.type !== "blob" || !/^100(644|755)$/.test(e.mode)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has unsupported mode ${e.mode}`, { path: shown, why: "device", key: ref.key, commit });
587
+ total += e.size;
588
+ if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
589
+ blobs.push(e);
590
+ }
591
+ blobs.sort(byPath);
592
+ assertNoCollisions(entries, ref, commit, rel);
593
+ mkdirSync(dirname(dest), { recursive: true });
594
+ const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
595
+ try {
596
+ mkdirSync(staging, { mode: 0o755 });
597
+ for (const t of trees) mkdirSync(join(staging, ...t.path.split("/")), { recursive: true, mode: 0o755 });
598
+ // One canonical pass over blobs AND links in byte order of path: contentDigest
599
+ // walks the written tree in the same order, so the two digests agree whatever
600
+ // the git tree listed first.
601
+ const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
602
+ const d = createDigest();
603
+ for (const { kind, e } of items) {
604
+ if (kind === "blob") {
605
+ const b = e;
606
+ let out;
607
+ try { out = await repo.local(["cat-file", "blob", b.oid], { maxBuffer: TREE_BUDGET + 1024 }); }
608
+ catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
609
+ const mode = b.mode === "100755" ? 0o755 : 0o644;
610
+ const target = join(staging, ...b.path.split("/"));
611
+ mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
612
+ const fd = openSync(target, "wx", mode);
613
+ try { writeSync(fd, out.stdout); } finally { closeSync(fd); }
614
+ d.add(b.path, mode, out.stdout);
615
+ continue;
616
+ }
617
+ const l = e;
618
+ let out;
619
+ try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
620
+ catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
621
+ const linkTarget = out.stdout.toString("utf8").trim();
622
+ const from = dirname(l.path === "" ? "x" : l.path);
623
+ const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
624
+ if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {
625
+ throw fail("E_REMOTE_TREE_UNSAFE", `${rel ? `${rel}/` : ""}${l.path} is a symlink escaping the fetched tree (${linkTarget})`, { path: l.path, why: "symlink", key: ref.key, commit });
626
+ }
627
+ const target = join(staging, ...l.path.split("/"));
628
+ mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
629
+ symlinkSync(linkTarget, target);
630
+ d.add(l.path, 0o777, Buffer.from(`symlink:${linkTarget}`));
631
+ }
632
+ const summary = d.finish();
633
+ renameSync(staging, dest);
634
+ return summary;
635
+ } catch (error) {
636
+ rmSync(staging, { recursive: true, force: true });
637
+ throw error;
638
+ }
639
+ }