@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/packages.mjs CHANGED
@@ -1,207 +1,105 @@
1
1
  /**
2
- * OATS distribution packages — WS2 policy layer (config bootstrap and workspace
3
- * reconciliation) over the package ENGINE in lib/core.mjs.
2
+ * OATS packages — versions, lock v3, approval (workspace model v2).
4
3
  *
5
- * The engine owns source parsing, manifests, the store, lock v2, acquisition,
6
- * exact restore, capability indexing, and trust (docs/design/
7
- * package-engine-contract.md + package-runtime-api.md). This module carries
8
- * ONLY what the Decision assigns to workstream 2:
9
- * - config profile selection/validation/provenance and the report-only diff;
10
- * - team-boundary workspace scope discovery (pruned, deterministic);
11
- * - host-requirement consent policy (identity/conflict fail-closed, plans).
4
+ * Contract: docs/design/2026-09-23-workspace-module-contracts.md §4.
5
+ * Decision: agents/oats-expert/soul/knowledge/decisions/workspace-model-v2.md.
12
6
  *
13
- * Runtime-neutral and dependency-free, like lib/core.mjs.
7
+ * A package is a place to fetch from WITH a version attached. Nothing is
8
+ * installed: `resolvePackages` turns each `workspace.packages` entry into an
9
+ * exact (commit, integrity) pair recorded in `oats-lock.json`
10
+ * (lockfileVersion 3), and the one-time executable approval per version lives
11
+ * next to the commit it approved. A moved tag (same version string, different
12
+ * commit) fails integrity and asks again.
13
+ *
14
+ * This module is synchronous except `resolvePackages`, shells out to nothing,
15
+ * and depends only on `node:*`. Remote access is INJECTED (`remote` option) so
16
+ * callers pass `lib/remote.mjs` and tests pass an in-memory fake.
17
+ *
18
+ * Lock v3 shape (exactly as the contract):
19
+ *
20
+ * {
21
+ * lockfileVersion: 3,
22
+ * packages: {
23
+ * <id>: {
24
+ * source: "catalog:<id>" | "git:<key>@<ref>",
25
+ * url: "<repo url the package was read from>", // repo identity: resolve/materialize need no catalog
26
+ * path: "<dir of oats-package.json inside the repo>",
27
+ * version: "<version string, no leading v>",
28
+ * commit: "<full 40-hex OID>",
29
+ * integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
30
+ * capabilities: ["<cap name>", …], // sorted
31
+ * approved: { executables: "sha256-<hex>", at: "<ISO-8601 UTC>" } | null
32
+ * }
33
+ * }
34
+ * }
35
+ *
36
+ * `packageTree` (input of `executablesDigest`):
37
+ *
38
+ * { manifests: [ { name: "<cap name>", manifest: <parsed oats.json>, files: Map<relpath, Buffer> } ] }
39
+ *
40
+ * `files` holds the bytes of the capability directory, keyed by POSIX
41
+ * relative path from that directory (e.g. "bin/tool.mjs"). Each
42
+ * `manifest.commands[<cmd>]` value is "<relpath> [args…]"; the FIRST token is
43
+ * the executable target whose bytes are digested. Only command targets are
44
+ * digested — skills, injects and other files are covered by `integrity`.
45
+ *
46
+ * Deprecated shims: every name the 0.24 kernel still imports from this module
47
+ * is exported below as a thin function throwing E_REMOVED so nothing breaks at
48
+ * import time; callers are deleted in the next phase.
14
49
  */
15
- import {
16
- chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
17
- readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync,
18
- } from "node:fs";
50
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, lstatSync } from "node:fs";
19
51
  import { tmpdir } from "node:os";
20
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
21
- import { execFileSync } from "node:child_process";
52
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
22
53
  import { createHash } from "node:crypto";
23
- import {
24
- DEFAULT_PACKAGE_PATH, OATS_LOCK_FILE, OAS_SCOPE_FILES, detectOasScopes, findRoot, installedCapabilitiesDir, listAgents, listInstalledPackages, loadPackageManifestAt, officialCapabilityAliases, packageSpecIdentity,
25
- gitCheckoutExactRef, parsePackageSource, resolveClaudeBinary, resolvePackageRoot,
26
- OATS_VERSION, parseYamlNested, readPackageLocks, resolveOatsConfig, RUNTIME_PACKAGE_MANAGERS,
27
- runtimePackageInstalled, runtimePackageIdentity, runtimePackageStatus, safeRuntimePackageSpec,
28
- safeRuntimeSourceRef, validateConfigShape, withConfigFile,
29
- } from "./core.mjs";
30
-
31
- // Runtime-package primitives live in the ENGINE (core.mjs) so spawn can use them
32
- // without this policy module being imported from there. Re-exported here because
33
- // the consent policy is this module's public surface.
34
- export { packageSpecIdentity, RUNTIME_PACKAGE_MANAGERS, runtimePackageIdentity, runtimePackageInstalled, runtimePackageStatus, safeRuntimePackageSpec };
35
-
36
- /** Throw an Error carrying a stable contract error code (engine taxonomy §4 + WS2 codes). */
37
- function fail(code, message) {
38
- const e = new Error(message);
39
- e.code = code;
40
- throw e;
41
- }
54
+ import { oatsError as baseOatsError } from "./errors.mjs";
55
+ import * as defaultRemote from "./remote.mjs";
42
56
 
43
- const AGENT_TYPE_RE = /^[a-z][a-z0-9-]*$/;
57
+ export const LOCK_FILE = "oats-lock.json";
58
+ export const LOCK_VERSION = 3;
59
+ export const PACKAGE_MANIFEST = "oats-package.json";
60
+ export const CAPABILITY_MANIFEST = "oats.json";
61
+ /** Default location of `oats-package.json` inside a package repo when the
62
+ * workspace value carries no path (the catalog supplies one for catalog ids). */
63
+ export const DEFAULT_PACKAGE_PATH = "oats-package";
44
64
 
45
- // ---------- config templates (selection, validation, adoption) ----------
46
- //
47
- // Both engine readers — acquisition's staged `configTemplates` and
48
- // `readLockedConfigTemplates` — produce the SAME descriptor, so everything here
49
- // takes descriptors and never cares which reader produced them.
65
+ const OID_RE = /^[0-9a-f]{40}$/;
66
+ const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
67
+ const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
50
68
 
51
- /** Scope-relative home of adopted template bases. Visible and commit-safe by
52
- * design: sync's three-way comparison needs the original base under review. */
53
- export const ADOPTED_TEMPLATES_DIRNAME = join(".agents", "config-templates", "adopted");
54
- export const ADOPTION_METADATA_FILE = "adoption.json";
69
+ /** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
70
+ * `e.details` (the contract's name) so callers can read either. */
71
+ function oatsError(code, message, details) {
72
+ const e = baseOatsError(code, message, details);
73
+ if (details !== undefined) e.details = details;
74
+ return e;
75
+ }
55
76
 
56
- /** Package and template names become PATH SEGMENTS under the adopted root, so
57
- * they get the same directory-name grammar a capability id gets. */
58
- const ADOPTED_TOKEN_RE = /^[a-z0-9][a-z0-9._-]*$/;
77
+ // ---------- generic file-safety helpers (kept: used by bin/oats.mjs outside package code) ----------
59
78
 
60
- export const adoptedTemplateDir = (levelDir, packageId, templateName) => {
61
- for (const [what, token] of [["package", packageId], ["template", templateName]]) {
62
- if (typeof token !== "string" || !ADOPTED_TOKEN_RE.test(token)) {
63
- fail("E_ADOPTED_PATH_UNSAFE", `adopted template path refused: ${what} token ${JSON.stringify(token)} is not a safe directory name (expected ${ADOPTED_TOKEN_RE.source})`);
64
- }
65
- }
66
- return join(levelDir, ADOPTED_TEMPLATES_DIRNAME, packageId, templateName);
67
- };
79
+ const isInside = (base, target) => target === base || target.startsWith(base + sep);
68
80
 
69
- /** Positively validate every EXISTING parent from the scope down to `leafDir`,
70
- * immediately before a write.
71
- *
72
- * An adopted base is written under a path the operator controls, and any
73
- * intermediate component could be a symlink — placed deliberately, or left over
74
- * from an unrelated tool. Following one writes the run's bytes somewhere nobody
75
- * asked for. The check refuses ANY intermediate symlink, contained or escaping:
76
- * a link that happens to stay inside the scope today is still a redirection
77
- * this code never sanctioned, and distinguishing the two only adds a way to be
78
- * wrong. Components that do not exist yet are fine — this run creates them.
79
- *
80
- * Deliberately re-run immediately before each write rather than cached: the
81
- * gap between checking and writing is exactly where a swap would land. */
81
+ /** Refuse to write below `scopeDir` through any symlinked path component of
82
+ * `leafDir`. Components that do not exist yet are fine — this run creates them.
83
+ * Re-run immediately before each write rather than cached. */
82
84
  export function assertNoSymlinkedParents(scopeDir, leafDir, what) {
83
85
  const base = resolve(scopeDir);
84
86
  const leaf = resolve(leafDir);
85
- if (!isInside(base, leaf)) fail("E_ADOPTED_PATH_UNSAFE", `${what} resolves outside the scope: ${leaf}`);
87
+ if (!isInside(base, leaf)) throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} resolves outside the scope: ${leaf}`, { scope: base, path: leaf });
86
88
  const rel = relative(base, leaf);
87
89
  let cur = base;
88
- // The scope root itself is the caller's own anchor, so the walk starts BELOW
89
- // it and covers every component this code is responsible for.
90
90
  for (const part of rel ? rel.split(sep) : []) {
91
91
  cur = join(cur, part);
92
92
  let st;
93
- try { st = lstatSync(cur); } catch { return; } // absent from here down: this run creates it
93
+ try { st = lstatSync(cur); } catch { return; }
94
94
  if (st.isSymbolicLink()) {
95
- fail("E_ADOPTED_PATH_UNSAFE", `${what} passes through a symlink at ${cur} — OATS never writes through a link it did not create; remove or replace that entry`);
96
- }
97
- if (!st.isDirectory()) fail("E_ADOPTED_PATH_UNSAFE", `${what} passes through ${cur}, which is not a directory`);
98
- }
99
- }
100
-
101
- /** Replace a file with an exact copy of another, never writing THROUGH the
102
- * destination. `copyFileSync` opens the destination for write and therefore
103
- * FOLLOWS it: a pre-planted `oats-config.yaml.bak` symlink would redirect the
104
- * copy onto whatever it points at. Sibling temp + rename replaces the entry. */
105
- export function copyFileAtomic(from, to) {
106
- writeFileAtomic(to, readFileSync(from));
107
- }
108
-
109
- /** Choose a template: explicit name, else the single marked default, else the
110
- * only one. Several unmarked templates need an explicit choice — guessing would
111
- * silently adopt a policy nobody picked. */
112
- export function selectConfigTemplate(templates, name, packageId, bail) {
113
- const refuse = (code, msg) => (bail ? bail(code, msg) : fail(code, msg));
114
- const list = templates || [];
115
- if (!list.length) return refuse("E_NO_TEMPLATES", `package ${packageId} exports no config templates`);
116
- if (name) {
117
- const hit = list.find((t) => t.template === name);
118
- if (!hit) return refuse("E_TEMPLATE_NOT_FOUND", `package ${packageId} has no config template "${name}" (templates: ${list.map((t) => t.template).join(", ")})`);
119
- return hit;
120
- }
121
- const marked = list.filter((t) => t.default);
122
- if (marked.length === 1) return marked[0];
123
- if (list.length === 1) return list[0];
124
- return refuse("E_TEMPLATE_AMBIGUOUS", `package ${packageId} exports several config templates and none is marked default (${list.map((t) => t.template).join(", ")}) — pass --config <name>`);
125
- }
126
-
127
- /** Validate one config template's CONTENT before adoption. Same policy checks
128
- * the profile validator carried, now reading a descriptor's bytes rather than a
129
- * package directory: config schema; every referenced installed capability
130
- * supplied by the closure; layer agreement against the real provider manifest;
131
- * agent-type syntax; no scope-escaping paths.
132
- * `deferUnknownLayers` exists because acquisition's PREVIEW capability rows
133
- * carry no capability manifest, so a template binding a layer to one of the
134
- * package's OWN not-yet-materialized capabilities cannot have that binding
135
- * checked inside the pre-commit gate. Refusing there would make every package
136
- * whose template binds a layer unadoptable, and accepting silently would be
137
- * fail-open — so the gate defers exactly those bindings and the caller
138
- * re-validates against the real materialized manifests before finalizing,
139
- * rolling the whole run back if they disagree. Same user-visible outcome as a
140
- * gate refusal (nothing persists), just paid for after the copy.
141
- * Returns a list of error strings (empty = valid). */
142
- export function validateConfigTemplate(descriptor, packageId, { dependencyProviders = new Map(), deferUnknownLayers = false } = {}) {
143
- const where = `config template "${descriptor.template}" of package ${packageId}`;
144
- let cfg;
145
- try {
146
- // A template is config SOURCE MATERIAL read from package bytes, so it hits
147
- // the same typed key refusal as a config on disk — and it must name the
148
- // template, not just "a mapping key". Same rendering as validateConfigShape
149
- // below, which already carries the template path.
150
- cfg = withConfigFile(`${where} (${descriptor.path})`, () => parseYamlNested(String(descriptor.content)));
151
- validateConfigShape(cfg, `${where} (${descriptor.path})`);
152
- } catch (e) { return [e.message]; }
153
- return validateAdoptableConfig(cfg, where, dependencyProviders, deferUnknownLayers);
154
- }
155
-
156
- /** The shared config-policy checks, over an already-parsed config. */
157
- function validateAdoptableConfig(cfg, where, dependencyProviders, deferUnknownLayers = false) {
158
- const errors = [];
159
- const supplied = new Set(dependencyProviders.keys());
160
- const entries = [];
161
- const caps = cfg.capabilities || {};
162
- for (const [layer, entry] of Object.entries(caps.layers || {})) {
163
- if (entry && typeof entry === "object") entries.push({ id: entry.capability, entry, slot: layer });
164
- }
165
- for (const [id, entry] of Object.entries(caps.additive || {})) entries.push({ id, entry: entry && typeof entry === "object" ? entry : {}, slot: undefined });
166
-
167
- for (const { id, entry, slot } of entries) {
168
- const from = String(entry.from || "installed");
169
- if (from.startsWith("path:")) { errors.push(`${where}: capability ${id} uses "from: ${from}" — templates must reference installed capabilities, not host paths`); continue; }
170
- if (from === "installed" && !supplied.has(id)) {
171
- errors.push(`${where}: capability ${id} is not supplied by the package or its dependency closure (supplied: ${[...supplied].join(", ") || "none"})`);
172
- continue;
173
- }
174
- if (slot) {
175
- const capMan = dependencyProviders.get(id) ?? null;
176
- if (capMan && capMan.layer !== slot) errors.push(`${where}: layer ${slot} binds ${id}, but its manifest declares layer "${capMan.layer || "none"}"`);
177
- else if (capMan === null && supplied.has(id) && !deferUnknownLayers) {
178
- errors.push(`${where}: layer ${slot} binds ${id}, but its provider manifest is not available to verify the layer — install the provider first`);
179
- }
180
- }
181
- const override = entry["injection-override"];
182
- if (typeof override === "string" && (isAbsolute(override) || override.split(/[\\/]/).includes(".."))) {
183
- errors.push(`${where}: capability ${id} injection-override escapes the target scope: ${override}`);
95
+ throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} passes through a symlink at ${cur} — OATS never writes through a link it did not create; remove or replace that entry`, { path: cur });
184
96
  }
97
+ if (!st.isDirectory()) throw oatsError("E_ADOPTED_PATH_UNSAFE", `${what} passes through ${cur}, which is not a directory`, { path: cur });
185
98
  }
186
- for (const [name] of Object.entries(cfg["agent-types"] || {})) {
187
- if (!AGENT_TYPE_RE.test(name)) errors.push(`${where}: agent type "${name}" must be lowercase alphanumeric/hyphens`);
188
- }
189
- for (const [mode, wm] of Object.entries(cfg["work-modes"] || {})) {
190
- const setup = wm && typeof wm === "object" ? wm.setup : undefined;
191
- if (typeof setup === "string" && (isAbsolute(setup) || setup.split(/[\\/]/).includes(".."))) {
192
- errors.push(`${where}: work-modes.${mode}.setup escapes the target scope: ${setup}`);
193
- }
194
- }
195
- const oatsOverride = cfg.oats && typeof cfg.oats === "object" ? cfg.oats["injection-override"] : undefined;
196
- if (typeof oatsOverride === "string" && (isAbsolute(oatsOverride) || oatsOverride.split(/[\\/]/).includes(".."))) {
197
- errors.push(`${where}: oats.injection-override escapes the target scope: ${oatsOverride}`);
198
- }
199
- return errors;
200
99
  }
201
100
 
202
- /** Atomically replace one file, never writing THROUGH it: a protected path may
203
- * be a symlink, and opening it for write would follow the link and clobber
204
- * whatever it points at. Sibling temp + rename replaces the entry itself. */
101
+ /** Sibling temp + rename: replaces the directory entry itself, never writing
102
+ * THROUGH a pre-planted symlink at `file`. */
205
103
  export function writeFileAtomic(file, contents) {
206
104
  mkdirSync(dirname(file), { recursive: true });
207
105
  const tmp = join(dirname(file), `.${basename(file)}.oats-tmp-${process.pid}`);
@@ -211,1165 +109,495 @@ export function writeFileAtomic(file, contents) {
211
109
  } finally { rmSync(tmp, { force: true }); }
212
110
  }
213
111
 
214
- /** Write the active config plus the exact adopted base and its metadata.
215
- *
216
- * The base is the template's EXACT bytes — not the local config — because the
217
- * three-way sync compares against it; a base that drifted toward local would
218
- * silently turn upstream changes into conflicts. The metadata copies the
219
- * engine's `contentIntegrity` verbatim rather than inventing a second digest,
220
- * and carries nothing machine-local: no absolute path, no host, no secret. */
221
- export function writeAdoptedTemplate(levelDir, configFile, { package: packageId, template, root }, { writeConfig = true } = {}) {
222
- const baseDir = adoptedTemplateDir(levelDir, packageId, template.template);
223
- const baseFile = join(baseDir, "oats-config.yaml");
224
- const metadataFile = join(baseDir, ADOPTION_METADATA_FILE);
225
- // A `path:` source is one developer's filesystem layout. This file is meant to
226
- // be COMMITTED, so recording it would leak a machine path into the repository
227
- // and mean nothing on any other checkout — record that the adoption was local
228
- // instead of where it happened. Git and catalog sources are portable and are
229
- // recorded verbatim.
230
- const rawSource = root?.source ?? null;
231
- const localSource = typeof rawSource === "string" && rawSource.startsWith("path:");
232
- const metadata = {
233
- package: packageId,
234
- template: template.template,
235
- templatePath: template.path,
236
- source: localSource ? null : rawSource,
237
- ...(localSource ? { localSource: true } : {}),
238
- version: root?.version ?? null,
239
- commit: root?.commit ?? null,
240
- packagePath: root?.path ?? null,
241
- hash: template.contentIntegrity,
242
- ...(template.legacySpelling ? { legacySpelling: true } : {}),
243
- adoptedWith: `oats ${OATS_VERSION}`,
244
- };
245
- // IMMEDIATELY before the writes, not once at command entry: every existing
246
- // component from the scope down to the template directory must be a real
247
- // directory this code can own.
248
- assertNoSymlinkedParents(levelDir, baseDir, `adopted base for ${packageId}:${template.template}`);
249
- // `sync` writes the MERGED config itself; only first adoption and reset copy
250
- // the template verbatim into place.
251
- if (writeConfig) writeFileAtomic(configFile, template.content);
252
- writeFileAtomic(baseFile, template.content);
253
- writeFileAtomic(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
254
- return { baseDir, baseFile, metadataFile, metadata };
112
+ /** Exact copy via `writeFileAtomic` (copyFileSync would follow a destination symlink). */
113
+ export function copyFileAtomic(from, to) {
114
+ writeFileAtomic(to, readFileSync(from));
255
115
  }
256
116
 
257
- /** Read this scope's adopted base + metadata, or null when nothing is adopted.
258
- * At most one adopted base may exist; more than one is a typed failure rather
259
- * than a guess about which is current. */
260
- export function readAdoptedTemplate(levelDir) {
261
- const root = join(levelDir, ADOPTED_TEMPLATES_DIRNAME);
262
- if (!existsSync(root)) return null;
263
- const found = [];
264
- for (const pkg of readdirSync(root, { withFileTypes: true })) {
265
- if (!pkg.isDirectory()) continue;
266
- for (const tpl of readdirSync(join(root, pkg.name), { withFileTypes: true })) {
267
- if (!tpl.isDirectory()) continue;
268
- const dir = join(root, pkg.name, tpl.name);
269
- if (existsSync(join(dir, ADOPTION_METADATA_FILE))) found.push({ package: pkg.name, template: tpl.name, dir });
270
- }
271
- }
272
- if (!found.length) return null;
273
- if (found.length > 1) {
274
- fail("E_MULTIPLE_ADOPTED_BASES", `scope ${levelDir} records ${found.length} adopted config templates (${found.map((f) => `${f.package}:${f.template}`).join(", ")}) — exactly one may be current; remove the stale ones under ${ADOPTED_TEMPLATES_DIRNAME}`);
275
- }
276
- const hit = found[0];
277
- let metadata;
278
- try { metadata = JSON.parse(readFileSync(join(hit.dir, ADOPTION_METADATA_FILE), "utf8")); }
279
- catch (e) { fail("E_ADOPTION_METADATA_INVALID", `adopted template metadata at ${join(hit.dir, ADOPTION_METADATA_FILE)} is unreadable: ${e.message}`); }
280
- const baseFile = join(hit.dir, "oats-config.yaml");
281
- if (!existsSync(baseFile)) fail("E_ADOPTION_BASE_MISSING", `adopted template ${hit.package}:${hit.template} has metadata but no recorded base at ${baseFile}`);
282
- return { ...hit, metadata, baseFile, baseText: readFileSync(baseFile, "utf8") };
283
- }
117
+ // ---------- lock v3 ----------
284
118
 
285
- // ---------- config template three-way merge (byte-preserving) ----------
286
- //
287
- // `oats config sync` compares three texts: the recorded ADOPTED BASE, the
288
- // current LOCAL oats-config.yaml, and the TEMPLATE from the currently locked
289
- // package. The Decision's binding rule is that untouched local bytes stay
290
- // byte-identical — comments, key ordering, blank lines, indentation style and
291
- // the presence or absence of a trailing newline all survive. That forbids the
292
- // obvious implementation (parse YAML, merge objects, reserialize): a round trip
293
- // through the kernel's YAML subset would rewrite the whole file even when one
294
- // key changed. So the merge is a line-level three-way diff whose output is
295
- // built by splicing ONLY the selected regions into the original local line
296
- // array; every other byte is copied verbatim from the local file.
297
- //
298
- // This half is deliberately engine-independent: pure text in, text out, no lock
299
- // reads, no filesystem, no package identity. The CLI supplies the three texts.
119
+ const emptyLock = () => ({ lockfileVersion: LOCK_VERSION, packages: {} });
120
+ const plainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
121
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
300
122
 
301
- /** Guard for the O(n*m) LCS tables below. Configs are tens to hundreds of lines;
302
- * anything past this is not a config being synchronized and must fail closed
303
- * rather than allocate gigabytes. */
304
- const MERGE_MAX_LINE_PRODUCT = 4_000_000;
123
+ function sortedObject(obj) {
124
+ const out = {};
125
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k];
126
+ return out;
127
+ }
305
128
 
306
- /** Split text into lines that RETAIN their exact terminators, so that
307
- * splitConfigLines(t).join("") === t for any t — including CRLF files and a
308
- * final line with no newline. Line identity is therefore byte identity: a line
309
- * that differs only in its terminator is a real difference, not a match. */
310
- export function splitConfigLines(text) {
311
- const s = String(text);
312
- const lines = [];
313
- let start = 0;
314
- for (let i = 0; i < s.length; i++) {
315
- if (s[i] === "\n") { lines.push(s.slice(start, i + 1)); start = i + 1; }
129
+ /** Structural validation of a lock v3 object. Throws E_LOCK_SCHEMA naming the
130
+ * offending path; a v1/v2 lock names its version and points at the rebuild. */
131
+ export function validateLock(lock, { file } = {}) {
132
+ const where = file ? ` (${file})` : "";
133
+ if (!plainObject(lock)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json must be a JSON object${where}`, { file, path: "/" });
134
+ const v = lock.lockfileVersion;
135
+ if (v !== LOCK_VERSION) {
136
+ const shown = v === undefined ? "missing" : JSON.stringify(v);
137
+ throw oatsError("E_LOCK_SCHEMA",
138
+ `oats-lock.json lockfileVersion ${shown} is not supported: this kernel reads lockfileVersion ${LOCK_VERSION} only (workspace model v2; no migration — delete the lock and run \`oats sync\`)${where}`,
139
+ { file, path: "/lockfileVersion", found: v, expected: LOCK_VERSION });
140
+ }
141
+ if (!plainObject(lock.packages)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json "packages" must be an object${where}`, { file, path: "/packages" });
142
+ for (const [id, entry] of Object.entries(lock.packages)) {
143
+ const at = `/packages/${id}`;
144
+ const bad = (field, why) => oatsError("E_LOCK_SCHEMA", `oats-lock.json ${at}/${field}: ${why}${where}`, { file, path: `${at}/${field}`, id });
145
+ if (!plainObject(entry)) throw oatsError("E_LOCK_SCHEMA", `oats-lock.json ${at} must be an object${where}`, { file, path: at, id });
146
+ if (typeof entry.source !== "string" || !/^(catalog:|git:)/.test(entry.source)) throw bad("source", 'must be "catalog:<id>" or "git:<key>@<ref>"');
147
+ if (entry.url !== undefined && (typeof entry.url !== "string" || !entry.url)) throw bad("url", "must be a non-empty repo url when present");
148
+ if (typeof entry.path !== "string" || !entry.path) throw bad("path", "must be a non-empty string");
149
+ if (typeof entry.version !== "string" || !entry.version) throw bad("version", "must be a non-empty string");
150
+ if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
151
+ if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
152
+ if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
153
+ if (entry.approved !== null) {
154
+ if (!plainObject(entry.approved)) throw bad("approved", "must be null or { executables, at }");
155
+ if (typeof entry.approved.executables !== "string" || !DIGEST_RE.test(entry.approved.executables)) throw bad("approved/executables", "must be sha256-<hex>");
156
+ if (typeof entry.approved.at !== "string" || Number.isNaN(Date.parse(entry.approved.at))) throw bad("approved/at", "must be an ISO-8601 timestamp");
157
+ }
316
158
  }
317
- if (start < s.length) lines.push(s.slice(start));
318
- return lines;
159
+ return lock;
319
160
  }
320
161
 
321
- /** Regions where `b` differs from `a`, as {aStart,aEnd,bStart,bEnd} half-open
322
- * line ranges. A pure insertion is a zero-width `a` range; a pure deletion a
323
- * zero-width `b` range. */
324
- function changeRegions(a, b) {
325
- const n = a.length, m = b.length;
326
- if (n * m > MERGE_MAX_LINE_PRODUCT) {
327
- fail("E_SYNC_TOO_LARGE", `config texts are too large to merge line-by-line (${n} x ${m} lines)`);
328
- }
329
- const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
330
- for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
331
- lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
162
+ /** Read `<dir>/oats-lock.json`. A missing file is an empty v3 lock; anything
163
+ * else is validated (E_LOCK_SCHEMA). Returns a fresh object every call. */
164
+ export function readLock(dir) {
165
+ const file = join(resolve(dir), LOCK_FILE);
166
+ if (!existsSync(file)) return emptyLock();
167
+ let parsed;
168
+ try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
169
+ throw oatsError("E_LOCK_SCHEMA", `oats-lock.json is not valid JSON (${file}): ${e.message}`, { file, path: "/" });
332
170
  }
333
- const regions = [];
334
- let cur = null;
335
- const flush = () => { if (cur) { regions.push(cur); cur = null; } };
336
- let i = 0, j = 0;
337
- while (i < n && j < m) {
338
- if (a[i] === b[j]) { flush(); i++; j++; continue; }
339
- if (!cur) cur = { aStart: i, aEnd: i, bStart: j, bEnd: j };
340
- if (lcs[i + 1][j] >= lcs[i][j + 1]) cur.aEnd = ++i;
341
- else cur.bEnd = ++j;
342
- }
343
- if (i < n || j < m) {
344
- if (!cur) cur = { aStart: i, aEnd: i, bStart: j, bEnd: j };
345
- cur.aEnd = n; cur.bEnd = m;
346
- }
347
- flush();
348
- return regions;
171
+ return validateLock(parsed, { file });
349
172
  }
350
173
 
351
- /** Line-count delta a region introduces on the non-base side. */
352
- const regionDelta = (r) => (r.bEnd - r.bStart) - (r.aEnd - r.aStart);
174
+ /** Canonical lock text: sorted package ids, sorted capability names, 2-space indent, trailing newline. */
175
+ export function canonicalLock(lock) {
176
+ validateLock(lock);
177
+ const packages = {};
178
+ for (const id of Object.keys(lock.packages).sort()) {
179
+ const e = lock.packages[id];
180
+ packages[id] = {
181
+ source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
182
+ capabilities: [...e.capabilities].sort(),
183
+ approved: e.approved ? { executables: e.approved.executables, at: e.approved.at } : null,
184
+ };
185
+ }
186
+ return { lockfileVersion: LOCK_VERSION, packages };
187
+ }
353
188
 
354
- function digestOf(...parts) {
355
- const hash = createHash("sha256");
356
- for (const part of parts) { hash.update(String(part)); hash.update("\0"); }
357
- return `sha256-${hash.digest("hex")}`;
189
+ /** Write `<dir>/oats-lock.json` atomically in canonical form. Returns the file path. */
190
+ export function writeLock(dir, lock) {
191
+ const file = join(resolve(dir), LOCK_FILE);
192
+ writeFileAtomic(file, JSON.stringify(canonicalLock(lock), null, 2) + "\n");
193
+ return file;
358
194
  }
359
195
 
360
- /** Three-way merge PLAN for one config: what changed where, and what a caller
361
- * may decide about it. Nothing is written and nothing is chosen here.
362
- *
363
- * Each returned region carries the exact text of all three sides plus:
364
- * kind "upstream" — only the template moved away from the base; may be offered
365
- * "local" — only the local file moved; it always stays (never offered away)
366
- * "conflict" — both moved, differently; needs an explicit local/package/edit choice
367
- * "agreed" — both moved to the same text; already in sync, nothing to do
368
- * recommended the decision applyConfigMerge uses when the caller passes none —
369
- * null for conflicts, which therefore cannot be resolved silently
370
- * digest binds the decision to the exact three texts the plan was built
371
- * from, so a stored automation answer cannot be replayed onto
372
- * shifted content
373
- *
374
- * Region `local` ranges are indices into splitConfigLines(localText); they are
375
- * what applyConfigMerge splices, which is why round-tripping a plan with no
376
- * upstream application returns the local bytes unchanged. */
377
- export function planConfigMerge(baseText, localText, templateText) {
378
- const base = splitConfigLines(baseText);
379
- const local = splitConfigLines(localText);
380
- const template = splitConfigLines(templateText);
196
+ // ---------- workspace `packages:` values ----------
381
197
 
382
- const localRegions = changeRegions(base, local);
383
- const templateRegions = changeRegions(base, template);
198
+ /** Strip a single leading "v" from a semver-ish tag ("v2.1.3" → "2.1.3"). */
199
+ const versionOf = (text) => text.replace(/^v(?=\d)/, "");
384
200
 
385
- // Group by overlapping-or-touching BASE range. Touching counts: an insertion
386
- // at line p (zero-width base range) and a replacement starting at p are the
387
- // same disputed spot, and treating them as independent would interleave two
388
- // sides' edits at one point without either side ever agreeing to it.
389
- const marked = [
390
- ...localRegions.map((r) => ({ side: "local", r })),
391
- ...templateRegions.map((r) => ({ side: "template", r })),
392
- ].sort((x, y) => x.r.aStart - y.r.aStart || x.r.aEnd - y.r.aEnd);
201
+ /** The two — and only two — `packages:` value forms (contract §2, non-collapse rule):
202
+ * catalog version: v2.1.3 | 2.1.3 | v1.0.0-rc.1 (resolves through the official catalog)
203
+ * direct git ref: git:<repo>@<ref> (<repo> = any ref lib/remote.mjs understands;
204
+ * <ref> = tag/branch name or full OID)
205
+ * Shared by lib/workspace.mjs (schema validation) and resolvePackages. */
206
+ export const CATALOG_VERSION_RE = /^v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$/;
207
+ const REF_NAME_RE = /^(?![-/.])(?!.*\.\.)(?!.*[\s~^:?*[\\\x00-\x1f\x7f])(?!.*@\{)(?!.*\/\.)(?!.*\.lock(?:\/|$))(?!.*\/\/)[^/]+(?:\/[^/]+)*(?<!\/|\.)$/;
393
208
 
394
- const groups = [];
395
- for (const item of marked) {
396
- const last = groups[groups.length - 1];
397
- if (last && item.r.aStart <= last.aEnd) {
398
- last.aEnd = Math.max(last.aEnd, item.r.aEnd);
399
- last.items.push(item);
400
- } else {
401
- groups.push({ aStart: item.r.aStart, aEnd: item.r.aEnd, items: [item] });
402
- }
209
+ /**
210
+ * Classify one `packages:` value WITHOUT touching the catalog or the network.
211
+ * → { kind: "catalog", version } | { kind: "git", repo, at } | { problem: "<why>" }
212
+ * `repo` is the remainder after `git:` when that remainder is itself a full ref
213
+ * (`/abs/bare.git`, `file:///…`, `https://…`, `git@host:…`), else `git:<host>/<path>` as written.
214
+ */
215
+ export function classifyPackageValue(value) {
216
+ if (typeof value !== "string" || !value.trim()) return { problem: "must be a non-empty string" };
217
+ const text = value.trim();
218
+ if (text !== value) return { problem: "must not carry surrounding whitespace" };
219
+ if (text.startsWith("git:")) {
220
+ const atIdx = text.lastIndexOf("@");
221
+ if (atIdx <= "git:".length) return { problem: "a git package must be written git:<repo>@<ref> (no @<ref> found)" };
222
+ const rest = text.slice("git:".length, atIdx);
223
+ const at = text.slice(atIdx + 1);
224
+ if (!at) return { problem: "a git package must be written git:<repo>@<ref> (empty <ref>)" };
225
+ if (rest.includes("@") && !/^git@[^@/:]+:/.test(rest)) return { problem: "a git package carries exactly one @<ref> (a repo ref has no @ except the git@host: ssh form)" };
226
+ if (!OID_RE.test(at) && !REF_NAME_RE.test(at)) return { problem: `${JSON.stringify(at)} is not a tag/branch name or full OID` };
227
+ if (!rest) return { problem: "a git package must be written git:<repo>@<ref> (empty <repo>)" };
228
+ // `git:` + an already-complete ref form: hand the remainder to parseRepoRef unchanged.
229
+ const repo = /^(?:\/|file:\/\/|https?:\/\/|git@)/.test(rest) ? rest : `git:${rest}`;
230
+ return { kind: "git", repo, at };
231
+ }
232
+ if (/^(?:https?:\/\/|file:\/\/|git@|\/)/.test(text) || text.includes("@") || text.includes("/")) {
233
+ return { problem: "a package outside the catalog must be written git:<repo>@<ref>" };
234
+ }
235
+ if (!CATALOG_VERSION_RE.test(text)) return { problem: `${JSON.stringify(text)} is neither a version (v2.1.3) nor git:<repo>@<ref>` };
236
+ return { kind: "catalog", version: versionOf(text) };
237
+ }
238
+
239
+ /** Split a catalog ref into a prefix and its version tail:
240
+ * "v2.1.3" → { prefix: "", vee: true } ; "oats-framework/v1.1.3" → { prefix: "oats-framework/", vee: true }. */
241
+ function refPattern(catalogRef) {
242
+ const m = /^(.*?)(v?)(\d[^/]*)$/.exec(catalogRef);
243
+ if (!m) return null;
244
+ return { prefix: m[1], vee: m[2] === "v" };
245
+ }
246
+
247
+ /** Recompose a catalog ref for a requested version, keeping the catalog's tag convention. */
248
+ function catalogRefFor(entry, requested) {
249
+ const pat = refPattern(entry.ref || "");
250
+ const bare = versionOf(requested);
251
+ if (!pat) return requested; // catalog ref carries no version tail — the request is the ref
252
+ return `${pat.prefix}${pat.vee ? "v" : ""}${bare}`;
253
+ }
254
+
255
+ /** Accept either the file shape ({ policy, packages: {…} }) or a bare id → entry map. */
256
+ function catalogPackages(catalog) {
257
+ if (!plainObject(catalog)) return {};
258
+ if (plainObject(catalog.packages) && !("url" in catalog.packages)) return catalog.packages;
259
+ return catalog;
260
+ }
261
+
262
+ /** Interpret one `workspace.packages` value for package `id`.
263
+ * - "<version>" → catalog lookup; ref recomposed from the catalog's tag convention
264
+ * - "git:<repo>@<ref>" → direct ref; `<repo>` is any repo ref `lib/remote.mjs` understands
265
+ * (git:host/path, https://…, git@host:…, /abs/bare.git, file:///…)
266
+ * Nothing else is accepted (E_WORKSPACE_SCHEMA for a malformed value, E_REPO_REF for a bad git form).
267
+ * → { kind: "catalog"|"git", remoteRef, at, path } */
268
+ export function parsePackageRequest(id, value, catalog) {
269
+ const c = classifyPackageValue(value);
270
+ if (c.problem) {
271
+ const code = typeof value === "string" && value.trim().startsWith("git:") ? "E_REPO_REF" : "E_WORKSPACE_SCHEMA";
272
+ throw oatsError(code, `packages.${id}: ${c.problem}, got ${JSON.stringify(value)}`, { id, value, path: `/packages/${id}`, problems: [c.problem] });
273
+ }
274
+ if (c.kind === "git") return { kind: "git", remoteRef: c.repo, at: c.at, path: DEFAULT_PACKAGE_PATH };
275
+ const text = value.trim();
276
+ const entry = catalogPackages(catalog)[id];
277
+ if (!plainObject(entry) || typeof entry.url !== "string") {
278
+ throw oatsError("E_PACKAGE_MISSING", `packages.${id}: ${JSON.stringify(text)} is a catalog version but the catalog has no package ${JSON.stringify(id)} — use git:<repo>@<ref> for a package outside the catalog`, { id, value: text });
279
+ }
280
+ return { kind: "catalog", remoteRef: entry.url, at: catalogRefFor(entry, text), path: typeof entry.path === "string" && entry.path ? entry.path : DEFAULT_PACKAGE_PATH };
281
+ }
282
+
283
+ // ---------- reading a package over the injected remote ----------
284
+
285
+ const pjoin = (...parts) => posix.normalize(posix.join(...parts));
286
+
287
+ const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
288
+
289
+ function assertRelPath(what, rel, details) {
290
+ if (typeof rel !== "string" || !rel || isAbsolute(rel) || /^[\\/]/.test(rel) || /[\x00]/.test(rel) || rel.split(/[\\/]/).some((p) => p === ".." || /^[A-Za-z]:$/.test(p))) {
291
+ throw oatsError("E_PACKAGE_MANIFEST", `${what} must be a relative path inside the package, got ${JSON.stringify(rel)}`, details);
292
+ }
293
+ }
294
+
295
+ /** Read + parse a JSON file of the package. A missing file is E_PACKAGE_MANIFEST (the
296
+ * package is malformed), never a leaked E_REMOTE_PATH_MISSING. */
297
+ async function readJson(remote, remoteRef, commit, path, what, details) {
298
+ let bytes;
299
+ try { ({ bytes } = await remote.readRemoteFile(remoteRef, commit, path)); } catch (e) {
300
+ if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `${what} is missing: no ${path} in ${typeof remoteRef === "string" ? remoteRef : remoteRef?.key}@${String(commit).slice(0, 12)}`, { ...details, path, cause: e.code });
301
+ throw e;
403
302
  }
404
-
405
- // Offsets of each side relative to the base, accumulated across groups: every
406
- // line outside a group is common to base and that side, so the mapping is
407
- // exact rather than approximate.
408
- let localOffset = 0, templateOffset = 0;
409
- const regions = [];
410
- for (const [index, group] of groups.entries()) {
411
- const span = group.aEnd - group.aStart;
412
- const localItems = group.items.filter((it) => it.side === "local").map((it) => it.r);
413
- const templateItems = group.items.filter((it) => it.side === "template").map((it) => it.r);
414
- const localStart = group.aStart + localOffset;
415
- const templateStart = group.aStart + templateOffset;
416
- const localLen = span + localItems.reduce((sum, r) => sum + regionDelta(r), 0);
417
- const templateLen = span + templateItems.reduce((sum, r) => sum + regionDelta(r), 0);
418
- localOffset += localLen - span;
419
- templateOffset += templateLen - span;
420
-
421
- const baseSlice = base.slice(group.aStart, group.aEnd).join("");
422
- const localSlice = local.slice(localStart, localStart + localLen).join("");
423
- const templateSlice = template.slice(templateStart, templateStart + templateLen).join("");
424
-
425
- const localChanged = localItems.length > 0;
426
- const templateChanged = templateItems.length > 0;
427
- let kind;
428
- if (localChanged && templateChanged) kind = localSlice === templateSlice ? "agreed" : "conflict";
429
- else if (templateChanged) kind = "upstream";
430
- else kind = "local";
431
-
432
- regions.push({
433
- id: `h${index + 1}`,
434
- kind,
435
- recommended: kind === "conflict" ? null : kind === "upstream" ? "package" : "local",
436
- digest: digestOf(baseSlice, localSlice, templateSlice),
437
- base: { start: group.aStart, end: group.aEnd, text: baseSlice },
438
- local: { start: localStart, end: localStart + localLen, text: localSlice },
439
- template: { start: templateStart, end: templateStart + templateLen, text: templateSlice },
440
- });
303
+ try { return JSON.parse(Buffer.from(bytes).toString("utf8")); } catch (e) {
304
+ throw oatsError("E_PACKAGE_MANIFEST", `${what} at ${path} is not valid JSON: ${e.message}`, { ...details, path });
441
305
  }
442
-
443
- const counts = { upstream: 0, local: 0, conflict: 0, agreed: 0 };
444
- for (const r of regions) counts[r.kind]++;
445
- return {
446
- regions,
447
- counts,
448
- conflicts: regions.filter((r) => r.kind === "conflict").map((r) => r.id),
449
- // "clean" means a caller can apply the recommendations without asking anyone.
450
- clean: counts.conflict === 0,
451
- localDigest: digestOf(localText),
452
- planDigest: digestOf(baseText, localText, templateText),
453
- };
454
306
  }
455
307
 
456
- /** Apply a plan's decisions to the LOCAL text, byte-preservingly.
457
- *
458
- * decisions: { [regionId]: "local" | "package" | { edit: "<replacement text>" } }
459
- * Regions with no decision fall back to `recommended`; a conflict has none, so
460
- * an unresolved conflict is a typed failure (E_SYNC_AMBIGUOUS) rather than a
461
- * silent pick — that is the noninteractive fail-closed rule.
462
- *
463
- * Returns { text, applied }. `applied` lists the regions that actually changed
464
- * bytes. With no decisions and no upstream regions, text === localText exactly,
465
- * byte for byte. */
466
- export function applyConfigMerge(localText, plan, decisions = {}) {
467
- const local = splitConfigLines(localText);
468
- // A plan carries the digest of the local text it was computed against. Line
469
- // indices are meaningless against any other text, so applying a plan the user
470
- // reviewed before the file changed under them must fail, not splice at the
471
- // old offsets.
472
- if (plan.localDigest && plan.localDigest !== digestOf(localText)) {
473
- fail("E_SYNC_STALE_PLAN", "the local oats-config.yaml changed after this merge plan was computed — re-run the diff and review the plan again");
474
- }
475
-
476
- const byId = new Map(plan.regions.map((r) => [r.id, r]));
477
- for (const id of Object.keys(decisions)) {
478
- if (!byId.has(id)) fail("E_SYNC_UNKNOWN_REGION", `no such change region "${id}" in this plan (regions: ${[...byId.keys()].join(", ") || "none"})`);
308
+ /** Read `oats-package.json` and every capability manifest it lists.
309
+ * → { manifest, capabilities: [{ name, dir, manifest }] } (sorted by name, codepoint order).
310
+ * Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }. */
311
+ export async function readPackageManifests(remote, remoteRef, commit, path, details = {}) {
312
+ const manifestPath = pjoin(path, PACKAGE_MANIFEST);
313
+ const manifest = await readJson(remote, remoteRef, commit, manifestPath, "package manifest", details);
314
+ if (!plainObject(manifest) || typeof manifest.package !== "string" || !Array.isArray(manifest.capabilities)) {
315
+ throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath} must declare "package" and "capabilities"`, { ...details, path: manifestPath });
479
316
  }
480
-
481
- const chosen = [];
482
- for (const region of plan.regions) {
483
- const raw = Object.hasOwn(decisions, region.id) ? decisions[region.id] : region.recommended;
484
- if (raw === null || raw === undefined) {
485
- fail("E_SYNC_AMBIGUOUS", `change region ${region.id} is a conflict — the local file and the package template both changed it, so it needs an explicit local/package/edit choice`);
317
+ const capabilities = [];
318
+ const seen = new Map();
319
+ for (const rel of manifest.capabilities) {
320
+ assertRelPath(`${manifestPath} capabilities[]`, rel, { ...details, path: manifestPath });
321
+ const dir = pjoin(path, rel);
322
+ const capManifest = await readJson(remote, remoteRef, commit, pjoin(dir, CAPABILITY_MANIFEST), "capability manifest", details);
323
+ if (!plainObject(capManifest) || typeof capManifest.capability !== "string" || !capManifest.capability) {
324
+ throw oatsError("E_PACKAGE_MANIFEST", `capability manifest at ${pjoin(dir, CAPABILITY_MANIFEST)} must declare "capability"`, { ...details, path: dir });
486
325
  }
487
- if (typeof raw === "object") {
488
- if (typeof raw.edit !== "string") fail("E_SYNC_BAD_DECISION", `change region ${region.id}: an edit decision needs { edit: "<text>" }`);
489
- chosen.push({ region, choice: "edit", text: raw.edit });
490
- continue;
326
+ if (seen.has(capManifest.capability)) {
327
+ throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares capability ${JSON.stringify(capManifest.capability)} twice (${seen.get(capManifest.capability)} and ${dir})`, { ...details, path: dir, duplicate: capManifest.capability, other: seen.get(capManifest.capability) });
491
328
  }
492
- if (raw !== "local" && raw !== "package") fail("E_SYNC_BAD_DECISION", `change region ${region.id}: decision must be "local", "package", or { edit }, not ${JSON.stringify(raw)}`);
493
- chosen.push({ region, choice: raw, text: raw === "local" ? region.local.text : region.template.text });
494
- }
495
-
496
- const out = [];
497
- const applied = [];
498
- let cursor = 0;
499
- for (const { region, choice, text } of chosen) {
500
- out.push(local.slice(cursor, region.local.start).join(""));
501
- let replacement = text;
502
- const isTail = region.local.end >= local.length;
503
- // A replacement that does not end in a newline while local content follows
504
- // would glue two YAML lines together. Terminate it instead of emitting a
505
- // corrupt config; the tail region legitimately may end without a newline.
506
- if (replacement && !replacement.endsWith("\n") && !isTail) replacement += "\n";
507
- out.push(replacement);
508
- if (replacement !== region.local.text) applied.push({ id: region.id, kind: region.kind, choice });
509
- cursor = region.local.end;
329
+ seen.set(capManifest.capability, dir);
330
+ capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
510
331
  }
511
- out.push(local.slice(cursor).join(""));
512
- return { text: out.join(""), applied };
513
- }
514
-
515
- // ---------- run-level rollback journal (CLI-private) ----------
516
- //
517
- // A multi-step init or template adoption touches several artifacts that no
518
- // single engine call spans: the active config, the lock, the flat installed
519
- // capability store, the capability .gitignore, and the adopted template base
520
- // plus its metadata. Engine operations are individually atomic and expose no
521
- // transaction handle, so the RUN-level guarantee — "a later failure rolls back
522
- // only this run's changes and preserves pre-existing bytes/artifacts" — is the
523
- // CLI's to keep. This journal is that mechanism, deliberately private to this
524
- // lane rather than a public kernel API.
525
- //
526
- // It is engine-independent by construction: paths in, bytes out. It knows the
527
- // scope layout and nothing about locks, packages, or capabilities.
528
-
529
- /** Scope-relative artifacts a run-level transaction must be able to undo. */
530
- export const RUN_JOURNAL_PATHS = Object.freeze([
531
- "oats-config.yaml",
532
- "oats-lock.json",
533
- ".agents/capabilities/installed",
534
- ".agents/capabilities/.gitignore",
535
- ".agents/config-templates/adopted",
536
- // The recoverable backup is run state too. Without it a failed sync could
537
- // leave a .bak from THIS run behind (or destroy one an earlier run left),
538
- // and rollback would report a clean failure that was not clean.
539
- "oats-config.yaml.bak",
540
- ]);
541
-
542
- /** The anchor whose creation is itself part of the run's changes. */
543
- const AGENTS_ANCHOR = ".agents";
544
-
545
- const isInside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : base + sep);
546
-
547
- /** Record what a path IS without following it: absent, symlink (+target), dir, or file (+mode). */
548
- function classifyPath(p) {
549
- let st;
550
- try { st = lstatSync(p); } catch { return { kind: "absent" }; }
551
- if (st.isSymbolicLink()) return { kind: "symlink", target: readlinkSync(p), mode: st.mode & 0o7777 };
552
- if (st.isDirectory()) return { kind: "dir", mode: st.mode & 0o7777 };
553
- return { kind: "file", mode: st.mode & 0o7777 };
332
+ capabilities.sort((a, b) => byCodepoint(a.name, b.name));
333
+ return { manifest, capabilities };
554
334
  }
555
335
 
556
- /** Reject ANY symlink in an INTERMEDIATE component of a protected path.
557
- *
558
- * One escaping out of the scope is the obvious danger: restoring through it
559
- * would delete or rewrite outer-scope state this run never owned. But a
560
- * CONTAINED alias is refused too, because it makes two protected paths address
561
- * the same bytes — restoring one entry then silently deletes or overwrites
562
- * another entry's artifact, and the outcome depends on entry order. A journal
563
- * whose entries can overlap cannot promise byte-exact restoration, so the only
564
- * safe posture is to refuse the layout rather than guess an ordering.
565
- *
566
- * A symlink AT the protected path itself is fine: it is captured and restored
567
- * verbatim and never written through. */
568
- function assertJournalContainment(scopeReal, rel) {
569
- const parts = rel.split("/").filter(Boolean);
570
- let cur = scopeReal;
571
- for (const part of parts.slice(0, -1)) {
572
- cur = join(cur, part);
573
- let st;
574
- try { st = lstatSync(cur); } catch { return; } // absent from here down: nothing to alias or escape through
575
- if (!st.isSymbolicLink()) continue;
576
- if (!isInside(scopeReal, realpathSync(cur))) {
577
- fail("E_JOURNAL_PATH_ESCAPE", `refusing to journal ${rel}: "${part}" is a symlink leaving ${scopeReal}`);
336
+ /** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
337
+ * copy into a scratch directory (contract §1: `contentDigest(dir)` is the local-directory form;
338
+ * the remote has no digest-without-copy primitive yet — see the deferred note in the review). */
339
+ async function packageIntegrity(remote, remoteRef, commit, path) {
340
+ if (typeof remote.fetchRemoteTree !== "function") {
341
+ throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package's integrity", { path: "/remote" });
342
+ }
343
+ const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-"));
344
+ const dest = join(scratch, "tree");
345
+ try {
346
+ const { digest } = await remote.fetchRemoteTree(remoteRef, commit, path, dest, { allowSymlinks: defaultRemote.OATS_ALIAS_SYMLINK });
347
+ return digest;
348
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
349
+ }
350
+
351
+ /** Depth bound for reading a capability directory: deep enough for any real layout. */
352
+ export const PACKAGE_TREE_DEPTH = 64;
353
+
354
+ /** Build a `packageTree` (see module header) for `executablesDigest` from the remote.
355
+ * Reads every blob under each capability directory. */
356
+ export async function readPackageTree(remote, remoteRef, commit, path, { depth = PACKAGE_TREE_DEPTH, remoteOptions } = {}) {
357
+ remote = bindRemote(remote, remoteOptions);
358
+ const { capabilities } = await readPackageManifests(remote, remoteRef, commit, path);
359
+ const manifests = [];
360
+ for (const cap of capabilities) {
361
+ const listing = await remote.listRemoteTree(remoteRef, commit, cap.dir, { depth });
362
+ const files = new Map();
363
+ for (const item of listing) {
364
+ if (item.type !== "blob") continue;
365
+ const { bytes } = await remote.readRemoteFile(remoteRef, commit, pjoin(cap.dir, item.path));
366
+ files.set(item.path, Buffer.from(bytes));
578
367
  }
579
- fail("E_JOURNAL_SYMLINK_COMPONENT", `refusing to journal ${rel}: "${part}" is a symlink aliasing another directory inside ${scopeReal} — two journal entries could then address the same bytes`);
368
+ manifests.push({ name: cap.name, manifest: cap.manifest, files });
580
369
  }
370
+ return { manifests };
581
371
  }
582
372
 
583
- /** Normalize one journalled relative path and reject anything ambiguous.
584
- * Fail-closed on purpose: this is a private API whose callers are in this
585
- * repository, so a malformed path is a bug to surface, never to interpret. */
586
- function canonicalJournalRel(rel, seen) {
587
- if (typeof rel !== "string") fail("E_JOURNAL_BAD_PATH", `journalled path must be a string, got ${typeof rel}`);
588
- const trimmed = rel.trim();
589
- if (!trimmed) fail("E_JOURNAL_BAD_PATH", "journalled path must be a nonempty relative path");
590
- if (trimmed.includes("\\")) fail("E_JOURNAL_BAD_PATH", `journalled path must use "/" separators: ${rel}`);
591
- if (isAbsolute(trimmed)) fail("E_JOURNAL_PATH_ESCAPE", `journalled path must stay inside the scope: ${rel}`);
592
- const parts = trimmed.split("/").filter((p) => p !== "" && p !== ".");
593
- if (parts.includes("..")) fail("E_JOURNAL_PATH_ESCAPE", `journalled path must stay inside the scope: ${rel}`);
594
- if (!parts.length) fail("E_JOURNAL_BAD_PATH", `journalled path resolves to the scope itself: ${rel}`);
595
- const canonical = parts.join("/");
596
- if (seen.has(canonical)) fail("E_JOURNAL_DUPLICATE_PATH", `journalled path listed more than once: ${canonical}`);
597
- seen.add(canonical);
598
- return canonical;
373
+ // ---------- resolvePackages ----------
374
+
375
+ function assertDigest(what, value, details) {
376
+ if (typeof value !== "string" || !DIGEST_RE.test(value)) throw oatsError("E_PACKAGE_INTEGRITY", `${what} is not a sha256-<hex> digest: ${JSON.stringify(value)}`, details);
377
+ return value;
599
378
  }
600
379
 
601
- /** Copy preserving type, symlink targets, modes, and timestamps.
602
- *
603
- * Hand-walked rather than fs.cpSync: cpSync's native recursion ABORTS THE
604
- * PROCESS with an uncatchable C++ filesystem_error when it meets an unreadable
605
- * directory (measured on node 22). A capability store with one bad-permission
606
- * directory would then kill the whole command with a libc++ message and no
607
- * cleanup, instead of a typed failure this journal can compensate. readdirSync
608
- * raises an ordinary catchable EACCES, which is what the constructor's cleanup
609
- * needs to work at all. */
610
- function copyExact(from, to) {
611
- const st = lstatSync(from);
612
- mkdirSync(dirname(to), { recursive: true });
613
- if (st.isSymbolicLink()) { symlinkSync(readlinkSync(from), to); return; }
614
- if (st.isDirectory()) {
615
- mkdirSync(to, { recursive: true });
616
- for (const name of readdirSync(from)) copyExact(join(from, name), join(to, name));
617
- // Mode and times go on AFTER the children: a read-only directory set first
618
- // would reject its own contents.
619
- chmodSync(to, st.mode & 0o7777);
620
- utimesSync(to, st.atime, st.mtime);
621
- return;
380
+ /** Bind `remoteOptions` (cacheDir, exec, …) into every call of a contract-§1 remote. */
381
+ export function bindRemote(remote, remoteOptions) {
382
+ if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
383
+ const bound = { ...remote };
384
+ for (const name of ["observeRemote", "readRemoteFile", "listRemoteTree", "fetchRemoteTree"]) {
385
+ if (typeof remote[name] !== "function") continue;
386
+ bound[name] = (...args) => {
387
+ const arity = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4 }[name];
388
+ const opts = { ...(args[arity] || {}), ...remoteOptions };
389
+ return remote[name](...args.slice(0, arity), opts);
390
+ };
622
391
  }
623
- if (!st.isFile()) fail("E_JOURNAL_UNSUPPORTED_ENTRY", `cannot journal ${from}: not a regular file, directory, or symlink`);
624
- copyFileSync(from, to);
625
- chmodSync(to, st.mode & 0o7777);
626
- utimesSync(to, st.atime, st.mtime);
392
+ return bound;
627
393
  }
628
394
 
629
- /** Open a run-level rollback journal over one scope.
395
+ /**
396
+ * Resolve every `workspace.packages` entry to an exact commit + integrity and
397
+ * merge into the lock (immutably — the input lock is never mutated).
630
398
  *
631
- * Snapshots every artifact in RUN_JOURNAL_PATHS (plus any `extraPaths`) exactly
632
- * as it is right now — bytes, file type, symlink targets, and mode bits — into a
633
- * backup directory OUTSIDE the protected tree, and records which ancestor
634
- * directories (including the `.agents` anchor) this run would be creating.
399
+ * options.catalog: package-catalog.json (file shape or its `packages` map)
400
+ * options.lock: current lock (v3) — defaults to an empty lock
401
+ * options.remote: { observeRemote, readRemoteFile, listRemoteTree, fetchRemoteTree } — defaults to
402
+ * lib/remote.mjs; tests pass an in-memory fake. options.remoteOptions (cacheDir, exec, …)
403
+ * is threaded into every remote call.
635
404
  *
636
- * Then exactly one of:
637
- * rollback() restore every snapshot, remove everything the run created, and
638
- * report truthfully — including partial failure.
639
- * finalize() the run succeeded: discard the backup. Call it only after every
640
- * command-owned write has finished, because it is the point of no
641
- * return.
642
- * Both are idempotent; rollback after finalize is a caller bug and throws. */
643
- export function beginRunJournal(scopeDir, { backupRoot = tmpdir(), extraPaths = [] } = {}) {
644
- const scope = resolve(scopeDir);
645
- if (!existsSync(scope)) fail("E_JOURNAL_NO_SCOPE", `cannot journal a run at ${scope}: the scope directory does not exist`);
646
- const scopeReal = realpathSync(scope);
647
-
648
- // Validate and canonicalize BEFORE staging anything, so a malformed call
649
- // never creates a backup directory it then has to clean up.
650
- const seen = new Set();
651
- const rels = [...RUN_JOURNAL_PATHS, ...extraPaths].map((rel) => canonicalJournalRel(rel, seen));
652
-
653
- // Directories that already existed. Anything NOT here that exists at rollback
654
- // time was created by this run and must be pruned once it is empty — which is
655
- // also what makes "remove a run-created .agents anchor only when empty" fall
656
- // out rather than being a special case.
657
- const preexistingDirs = new Set();
658
- const noteAncestors = (rel) => {
659
- const parts = rel.split("/").filter(Boolean);
660
- let cur = scope;
661
- for (const part of parts.slice(0, -1)) {
662
- cur = join(cur, part);
663
- if (existsSync(cur)) preexistingDirs.add(cur);
664
- }
665
- };
666
-
667
- const backupDir = mkdtempSync(join(backupRoot, "oats-run-journal-"));
668
- const entries = [];
669
- let anchorExisted;
670
- try {
671
- // The backup must not live inside the tree it protects: restoring a
672
- // directory means deleting it first, which would delete the backup with it.
673
- if (isInside(scopeReal, realpathSync(backupDir))) {
674
- fail("E_JOURNAL_BACKUP_INSIDE_SCOPE", `run-journal backup ${backupDir} would live inside the protected scope ${scopeReal}`);
675
- }
676
- for (const [index, rel] of rels.entries()) {
677
- assertJournalContainment(scopeReal, rel);
678
- noteAncestors(rel);
679
- const path = join(scope, rel);
680
- const state = classifyPath(path);
681
- // Keyed by INDEX, never by a flattened path: "a/b" and "a__b" are
682
- // different artifacts and any separator-substitution scheme collides them,
683
- // which would restore one entry's bytes over the other's.
684
- const backup = state.kind === "absent" ? null : join(backupDir, String(index));
685
- if (backup) copyExact(path, backup);
686
- entries.push({ rel, path, backup, ...state });
687
- }
688
- const anchorPath = join(scope, AGENTS_ANCHOR);
689
- anchorExisted = preexistingDirs.has(anchorPath) || existsSync(anchorPath);
690
- if (anchorExisted) preexistingDirs.add(anchorPath);
691
- } catch (e) {
692
- // A half-built journal protects nothing, so it must not outlive the
693
- // failure: leaving the partial backup behind would strand a copy of the
694
- // scope's bytes in the temp tree with no owner to clean it up.
695
- rmSync(backupDir, { recursive: true, force: true });
696
- throw e;
697
- }
698
-
699
- const anchorPath = join(scope, AGENTS_ANCHOR);
700
-
701
- let state = "open";
405
+ * → { lock, changes: [{ id, from, to, commit, approvalNeeded }] }
406
+ * - `from` is the previously locked version or null; `to` is the resolved version
407
+ * (null when the package was removed from the workspace and dropped from the lock).
408
+ * - `approvalNeeded` = the entry's `approved` is null.
409
+ * - A locked entry whose version string is unchanged but whose commit or
410
+ * integrity moved → E_PACKAGE_INTEGRITY { id, version, locked, observed }.
411
+ * - Unchanged entries keep their approval ONLY when the recorded executables digest still
412
+ * matches the tree (else E_PACKAGE_UNAPPROVED); a new version starts unapproved.
413
+ * - A value resolving to a BRANCH → E_PACKAGE_INTEGRITY { why: "branch" }: versions are immutable.
414
+ */
415
+ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLock(), remote = defaultRemote, remoteOptions } = {}) {
416
+ if (!remote || typeof remote.observeRemote !== "function" || typeof remote.readRemoteFile !== "function") {
417
+ throw new TypeError("resolvePackages: remote must provide observeRemote()/readRemoteFile() (module contract §1)");
418
+ }
419
+ remote = bindRemote(remote, remoteOptions);
420
+ const previous = validateLock(lock);
421
+ const requests = plainObject(workspace?.packages) ? workspace.packages : {};
422
+ const ids = Object.keys(requests).sort();
423
+ const nextPackages = {};
424
+ const changes = [];
702
425
 
703
- /** Restore one entry to exactly what it was; absence is itself a state to restore. */
704
- const restoreEntry = (entry, report) => {
705
- // Absent then, absent now: nothing to undo. Every other case is restored
706
- // unconditionally — comparing first would only save work, and a comparison
707
- // that is subtly wrong silently skips a restore the run depended on.
708
- if (entry.kind === "absent" && classifyPath(entry.path).kind === "absent") return;
709
- try {
710
- // Remove whatever is there now. On a symlink this unlinks the link
711
- // itself, so a hostile link target is never written through.
712
- rmSync(entry.path, { recursive: true, force: true });
713
- if (entry.kind === "absent") { report.removed.push(entry.rel); return; }
714
- copyExact(entry.backup, entry.path);
715
- if (entry.kind !== "symlink") chmodSync(entry.path, entry.mode);
716
- report.restored.push(entry.rel);
717
- } catch (e) {
718
- report.failures.push({ path: entry.rel, error: e.message });
426
+ for (const id of ids) {
427
+ const req = parsePackageRequest(id, requests[id], catalog);
428
+ const details = { id, value: requests[id] };
429
+ const obs = await remote.observeRemote(req.remoteRef, { at: req.at });
430
+ if (!obs || typeof obs.commit !== "string" || !OID_RE.test(obs.commit)) {
431
+ throw oatsError("E_PACKAGE_INTEGRITY", `packages.${id}: remote did not yield a full commit OID for ${req.remoteRef}@${req.at}`, { ...details, observed: obs?.commit });
719
432
  }
720
- };
721
-
722
- /** Remove directories this run created, deepest first, and only while empty. */
723
- const pruneCreatedDirs = (report) => {
724
- const candidates = new Set();
725
- for (const { rel } of entries) {
726
- const parts = rel.split("/").filter(Boolean);
727
- let cur = scope;
728
- for (const part of parts.slice(0, -1)) { cur = join(cur, part); candidates.add(cur); }
433
+ // A package version must be immutable: a branch is a moving target and is refused up front
434
+ // (a tag that later moves is caught below as E_PACKAGE_INTEGRITY).
435
+ if (typeof obs.ref === "string" && /^refs\/heads\//.test(obs.ref)) {
436
+ throw oatsError("E_PACKAGE_INTEGRITY", `packages.${id}: ${req.at} is a branch (${obs.ref}), not a version — pin a tag or a full commit OID`, { ...details, why: "branch", ref: obs.ref, commit: obs.commit });
729
437
  }
730
- candidates.add(anchorPath);
731
- for (const dir of [...candidates].sort((a, b) => b.length - a.length)) {
732
- if (preexistingDirs.has(dir) || !existsSync(dir)) continue;
733
- try {
734
- if (readdirSync(dir).length) continue; // not ours to empty — owned/ or a stranger's file lives here
735
- rmdirSync(dir);
736
- report.removed.push(dir.slice(scope.length + 1) || AGENTS_ANCHOR);
737
- } catch (e) {
738
- report.failures.push({ path: dir.slice(scope.length + 1) || AGENTS_ANCHOR, error: e.message });
438
+ const source = req.kind === "catalog" ? `catalog:${id}` : `git:${obs.key}@${req.at}`;
439
+ const version = req.kind === "catalog" ? versionOf(requests[id].trim()) : versionOf(req.at);
440
+ const old = previous.packages[id] || null;
441
+
442
+ // Same version + same source + same path as locked: the lock must still describe reality.
443
+ if (old && old.version === version && old.source === source && old.path === req.path) {
444
+ if (old.commit !== obs.commit) {
445
+ throw oatsError("E_PACKAGE_INTEGRITY",
446
+ `packages.${id} ${version} is locked at ${old.commit} but ${source} now resolves to ${obs.commit} — the tag moved; a version string must change when its content does`,
447
+ { ...details, version, locked: { commit: old.commit, integrity: old.integrity }, observed: { commit: obs.commit } });
739
448
  }
449
+ const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, old.path), details);
450
+ if (integrity !== old.integrity) {
451
+ throw oatsError("E_PACKAGE_INTEGRITY",
452
+ `packages.${id} ${version} @ ${obs.commit}: content digest ${integrity} does not match the locked ${old.integrity}`,
453
+ { ...details, version, locked: { commit: old.commit, integrity: old.integrity }, observed: { commit: obs.commit, integrity } });
454
+ }
455
+ // A recorded approval must describe THESE executables, not merely any well-formed digest.
456
+ if (old.approved) {
457
+ const executables = executablesDigest(await readPackageTree(remote, req.remoteRef, obs.commit, old.path));
458
+ if (executables !== old.approved.executables) {
459
+ throw oatsError("E_PACKAGE_UNAPPROVED",
460
+ `packages.${id} ${version}: the recorded approval ${old.approved.executables} does not match the package's executables ${executables} — approve again`,
461
+ { ...details, version, commit: obs.commit, approved: old.approved, executables });
462
+ }
463
+ }
464
+ nextPackages[id] = clone(old);
465
+ continue;
740
466
  }
741
- };
742
-
743
- return {
744
- scope,
745
- backupDir,
746
- anchorCreatedByRun: !anchorExisted,
747
- /** What the journal is protecting, for previews and diagnostics. */
748
- protected: entries.map(({ rel, kind }) => ({ path: rel, was: kind })),
749
- get state() { return state; },
750
-
751
- /** Undo this run. Attempts EVERY step even after one fails, so a partial
752
- * failure is reported in full rather than hidden behind the first error. */
753
- rollback() {
754
- if (state === "finalized") fail("E_JOURNAL_FINALIZED", "this run was already finalized — its backup is gone and it cannot be rolled back");
755
- const report = { restored: [], removed: [], failures: [], complete: true, summary: "" };
756
- if (state === "rolled-back") { report.summary = "nothing to roll back (already rolled back)"; return report; }
757
- for (const entry of entries) restoreEntry(entry, report);
758
- pruneCreatedDirs(report);
759
- report.complete = report.failures.length === 0;
760
- report.summary = report.complete
761
- ? `rolled back ${report.restored.length} restored, ${report.removed.length} removed`
762
- : `ROLLBACK INCOMPLETE — ${report.failures.length} of ${entries.length} artifact(s) could not be restored: ${report.failures.map((f) => `${f.path} (${f.error})`).join("; ")}`;
763
- // The backup survives an incomplete rollback: it is the only remaining
764
- // copy of the pre-run bytes, so destroying it would turn a recoverable
765
- // failure into permanent loss.
766
- if (report.complete) { rmSync(backupDir, { recursive: true, force: true }); state = "rolled-back"; }
767
- return report;
768
- },
769
-
770
- /** The run succeeded — drop the backup. Point of no return. */
771
- finalize() {
772
- if (state === "rolled-back") fail("E_JOURNAL_ROLLED_BACK", "this run was already rolled back and cannot be finalized");
773
- rmSync(backupDir, { recursive: true, force: true });
774
- state = "finalized";
775
- },
776
- };
777
- }
778
-
779
- /** Capability ids supplied by the visible locked packages of a scope.
780
- *
781
- * Reads the CAPABILITY rows, not the package rows: in the revised lock a
782
- * package row carries no capability list at all, and the capability row's
783
- * `package` back-reference is the single provider truth — which is exactly why
784
- * the two levels can no longer disagree. */
785
- export function lockedPackageCapabilities(startDir) {
786
- const out = new Map(); // capability id → [package ids]
787
- for (const [capId, row] of Object.entries(readPackageLocks(startDir).capabilities)) {
788
- if (!out.has(capId)) out.set(capId, []);
789
- out.get(capId).push(row.package);
790
- }
791
- return out;
792
- }
793
467
 
794
- /** Resolve a package id/path to its ENGINE-loaded manifest for profile
795
- * adoption/diff. Installed ids resolve through listInstalledPackages (the
796
- * engine's indexed store); local paths load directly. Git URLs are cloned by
797
- * the caller (adoption acquires; diff uses a temp clone). */
798
- export function resolveProfilePackage(src, dir, { clone } = {}) {
799
- // Classify through the ENGINE's parser, never a local regex: a diff that
800
- // disagreed with acquisition about what a source spells (which git
801
- // spellings count, which contained root is selected) would compare the
802
- // adopted snapshot against a profile the install never used.
803
- // A malformed source is a typed failure, never a fall-through to the
804
- // installed-id lookup: "#../escape" must report path-escape, not "not an
805
- // installed package id".
806
- const parsedSrc = parsePackageSource(src);
807
- if (parsedSrc.kind === "path") {
808
- const abs = parsedSrc.path; // absolute, tilde already expanded
809
- const manifest = loadPackageManifestAt(abs); // throws invalid-package-manifest with the engine code
810
- return { manifest, commit: "local", source: `path:${abs}` };
811
- }
812
- const isUrl = parsedSrc.kind === "git";
813
- if (isUrl) {
814
- if (!clone) fail("invalid-source", "git package sources need a clone directory (internal)");
815
- // Select the CONTAINED package root exactly the way acquisition does — the
816
- // profile a diff compares against must come from the same directory the
817
- // install would lock, not from whatever sits at the repository root.
818
- // The REF matters for the same reason: reading HEAD's profile while the
819
- // returned provenance claims "@v1" compares the snapshot against a version
820
- // the install never used. A shallow clone cannot check out an arbitrary
821
- // ref, so only the unpinned case stays shallow.
822
- // The ref is resolved and verified by the ENGINE before checkout: a
823
- // caller-supplied ref must never reach git as an option-capable argument
824
- // (see gitCheckoutExactRef).
825
- const ref = parsedSrc.ref;
826
- execFileSync("git", ["clone", "-q", ...(ref ? [] : ["--depth", "1"]), parsedSrc.url, clone], { stdio: ["ignore", "pipe", "pipe"] });
827
- const commit = ref
828
- ? gitCheckoutExactRef(clone, ref, src)
829
- : execFileSync("git", ["-C", clone, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
830
- const manifest = loadPackageManifestAt(resolvePackageRoot(clone, parsedSrc.packagePath ?? DEFAULT_PACKAGE_PATH, src));
831
- return { manifest, commit, source: parsedSrc.normalized };
468
+ const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
469
+ const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
470
+ nextPackages[id] = {
471
+ source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
472
+ capabilities: capabilities.map((c) => c.name).sort(),
473
+ approved: null,
474
+ };
475
+ changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit, approvalNeeded: true });
832
476
  }
833
- // Installed/locked package id visible from dir (engine indexing).
834
- // findLast: closest scope wins for a package identity (the listing is
835
- // outermost → innermost and may hold the same id at two levels).
836
- const pkg = listInstalledPackages(dir).findLast((p) => p.package === src);
837
- if (!pkg) fail("invalid-source", `"${src}" is not an installed package id, local path, or git URL at ${dir} — acquire it first with \`oats install <source>\``);
838
- return { manifest: pkg.manifest, commit: pkg.commit || "local", source: pkg.source };
839
- }
840
-
841
- // ---------- team-boundary workspace scope discovery ----------
842
-
843
- /** Directory names pruned during descendant scope discovery (dependency/vendor trees). */
844
- export const PRUNED_DIR_NAMES = new Set([".git", "node_modules", "vendor", ".venv", "venv", "bower_components", ".direnv"]);
845
-
846
- const isScopeDir = (dir) => existsSync(join(dir, "oats-config.yaml")) || existsSync(join(dir, OATS_LOCK_FILE));
847
- const declaresTeam = (dir) => {
848
- const file = join(dir, "oats-config.yaml");
849
- if (!existsSync(file)) return false;
850
- try { return !!parseYamlNested(readFileSync(file, "utf8")).team; } catch { return false; }
851
- };
852
-
853
- /** A distribution package ROOT — the directory carrying the oats-package.json
854
- * manifest. Everything BELOW it is package PAYLOAD (see its use in
855
- * walkBoundaryDirs). The literal filename matches the engine's readers in
856
- * lib/core.mjs; there is no custom manifest name in the contract. */
857
- const isPackageRoot = (dir) => existsSync(join(dir, "oats-package.json"));
858
-
859
- /** The one pruned walk under a team boundary: .git, generated stores
860
- * (.agents), dependency/vendor dirs, agent instance homes/worktrees,
861
- * local-agents, nested team boundaries, and package PAYLOAD (anything under an
862
- * oats-package.json manifest) are skipped. `visit` is called on every surviving
863
- * directory in deterministic path order; the boundary itself is not visited. */
864
- function walkBoundaryDirs(boundary, visit) {
865
- const walk = (dir) => {
866
- let entries;
867
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
868
- const names = entries.filter((e) => e.isDirectory() && !e.isSymbolicLink()).map((e) => e.name).sort((x, y) => x.localeCompare(y));
869
- for (const name of names) {
870
- if (PRUNED_DIR_NAMES.has(name)) continue;
871
- const child = join(dir, name);
872
- // Generated stores (.agents: capability/package stores, injections) never
873
- // contain deployment scopes; agent instance homes/worktrees and local
874
- // souls are runtime state, not workspace repositories.
875
- if (name === ".agents") continue;
876
- if (name === "local-agents") continue;
877
- if (name === "instances" && existsSync(join(dir, "soul"))) continue;
878
- // Nested team boundary: a descendant scope declaring its own team: is its own reconciliation unit.
879
- if (declaresTeam(child)) continue;
880
- visit(child);
881
- // Package PAYLOAD is content the package exports, not deployment scopes.
882
- // A member repo shipping a package carries config TEMPLATES (declared via
883
- // configTemplates, canonically under config-templates/) whose layers name
884
- // capabilities the adopting deployment has not installed. Reconciling one
885
- // as a live scope reported phantom "supplied by no visible locked package"
886
- // failures for the whole team. The rule is the MANIFEST, not the literal
887
- // template path: custom payload roots exist and one repo may ship several
888
- // packages at different paths. A config whose containing ANCESTOR holds an
889
- // oats-package.json is payload — so a repo root that is itself a package
890
- // root (no manifest ancestor) is still visited above.
891
- if (isPackageRoot(child)) continue;
892
- walk(child);
893
- }
894
- };
895
- // A boundary that is itself a package root makes every descendant payload.
896
- if (!isPackageRoot(boundary)) walk(boundary);
897
- }
898
477
 
899
- /** Deterministic path-order discovery of descendant scopes inside a team boundary.
900
- * Prunes .git, generated stores (.agents), dependency/vendor dirs, agent
901
- * instance homes/worktrees, local-agents, nested team boundaries, and package
902
- * PAYLOAD (anything under an oats-package.json manifest). The boundary itself
903
- * is NOT included. Returns sorted absolute paths. */
904
- export function discoverWorkspaceScopes(boundary) {
905
- const out = [];
906
- walkBoundaryDirs(boundary, (child) => { if (isScopeDir(child)) out.push(child); });
907
- return out;
908
- }
909
-
910
- /** Every VISIBLE lock-owning scope a guided migration must consider, in
911
- * deterministic path order (ancestors sort before their descendants):
912
- * - the explicit scope's ancestor chain, so an outer repo/laptop lock that
913
- * the deployment actually reads is migrated too, not silently left on v1;
914
- * - the team boundary when the scope declares one;
915
- * - descendant config/lock scopes under that boundary, using the same pruned
916
- * discovery reconciliation uses (nested team boundaries stay self-owned).
917
- * Only directories that actually own an oats-lock.json are returned — a scope
918
- * with no lock has nothing to migrate. */
919
- export function discoverMigrationScopes(startDir, { teamScope } = {}) {
920
- const ownsLock = (dir) => existsSync(join(dir, OATS_LOCK_FILE));
921
- const out = new Set();
922
- for (let d = resolve(startDir); ; d = dirname(d)) {
923
- if (ownsLock(d)) out.add(d);
924
- if (dirname(d) === d) break;
478
+ for (const id of Object.keys(previous.packages).sort()) {
479
+ if (!(id in requests)) changes.push({ id, from: previous.packages[id].version, to: null, commit: null, approvalNeeded: false });
925
480
  }
926
- const boundary = resolve(teamScope || startDir);
927
- if (ownsLock(boundary)) out.add(boundary);
928
- for (const s of discoverWorkspaceScopes(boundary)) if (ownsLock(s)) out.add(resolve(s));
929
- // Plain code-unit sort: deterministic, and a parent path is a prefix of its
930
- // children so ancestors are always planned and applied first.
931
- return [...out].sort();
932
- }
933
481
 
934
- /** Un-migrated OAS scopes a guided migration must never read as "nothing to
935
- * do": the ancestor chain plus the same pruned boundary walk migration scope
936
- * discovery uses. Returns [{dir, files}] in deterministic path order. */
937
- export function discoverOasScopes(startDir, { teamScope } = {}) {
938
- const oasFiles = (dir) => OAS_SCOPE_FILES.filter((f) => existsSync(join(dir, f)));
939
- const found = new Map();
940
- for (const f of detectOasScopes(startDir)) found.set(f.dir, f.files);
941
- const boundary = resolve(teamScope || startDir);
942
- const boundaryFiles = oasFiles(boundary);
943
- if (boundaryFiles.length) found.set(boundary, boundaryFiles);
944
- walkBoundaryDirs(boundary, (child) => {
945
- const files = oasFiles(child);
946
- if (files.length) found.set(resolve(child), files);
947
- });
948
- return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([dir, files]) => ({ dir, files }));
482
+ return { lock: { lockfileVersion: LOCK_VERSION, packages: sortedObject(nextPackages) }, changes };
949
483
  }
950
484
 
951
- // ---------- oats migrate --from-oas (aweb-abfy.3) ----------
952
-
953
- /** Legacy id → successor id, straight from the catalog's renaming aliases.
954
- * The catalog is the ONLY place that knows about renames; nothing here may
955
- * hard-code an oas.* → oats.* pair. */
956
- export function oasRenameMap() {
957
- const map = Object.create(null);
958
- for (const [legacy, target] of Object.entries(officialCapabilityAliases())) {
959
- if (target && typeof target === "object" && target.capability && target.capability !== legacy) map[legacy] = target.capability;
960
- }
961
- return map;
962
- }
485
+ // ---------- executables digest + approval ----------
963
486
 
964
- /** Pure line transform of an oas-config.yaml: the top-level `oas:` defaults
965
- * block becomes `oats:`, and capability ids with catalog renames become their
966
- * successors — in `capability:` values and in additive map keys. Line-based
967
- * on purpose: comments, ordering and every unrelated byte survive. */
968
- export function transformOasConfigText(text, renames) {
969
- const changes = [];
970
- const lines = text.split("\n").map((line) => {
971
- let out = line;
972
- if (/^oas:\s*(#.*)?$/.test(line)) out = line.replace(/^oas:/, "oats:");
973
- else {
974
- const cap = line.match(/^(\s*capability:\s*)([A-Za-z0-9_.-]+)(\s*(?:#.*)?)$/);
975
- if (cap && renames[cap[2]]) out = cap[1] + renames[cap[2]] + cap[3];
976
- else {
977
- const key = line.match(/^(\s+)([A-Za-z0-9_.-]+):(\s*(?:#.*)?)$/);
978
- if (key && renames[key[2]]) out = key[1] + renames[key[2]] + ":" + key[3];
979
- }
980
- }
981
- if (out !== line) changes.push({ from: line.trim(), to: out.trim() });
982
- return out;
983
- });
984
- return { text: lines.join("\n"), changes };
487
+ /** First token of a `commands` value is the executable target (e.g. "bin/x.mjs cut" → "bin/x.mjs"). */
488
+ export function commandTarget(specification) {
489
+ return String(specification).trim().split(/\s+/)[0] || "";
985
490
  }
986
491
 
987
- /** Everything `oats migrate --from-oas` would change at ONE scope, as explicit
988
- * steps, plus the validation of every transformed artifact BEFORE anything is
989
- * touched. Pure read. Steps cover breaks 1-3 of docs/migration-from-oas.md:
990
- * - oas-config.yaml → oats-config.yaml (line transform, validated);
991
- * - oas-lock.json → oats-lock.json (verbatim — entry ids stay oas.*; the
992
- * chained guided migration converts them via the renaming aliases);
993
- * - installed capability dirs: oas.json → oats.json (verbatim);
994
- * - agents/`*`/soul and local-agents/`*`/soul: .oas-scaffold-owners.json →
995
- * .oats-scaffold-owners.json with owner ids mapped to their successors.
996
- * Break 4 (stale v1 integrity) is deliberately NOT recomputed: the chained
997
- * migration re-acquires every official capability, which supersedes the
998
- * artifacts the integrity covered. */
999
- export function planFromOasScope(scope) {
1000
- const renames = oasRenameMap();
1001
- const steps = [];
1002
- const errors = [];
1003
- const addStep = (from, to, note, content) => {
1004
- if (existsSync(to)) { errors.push(`${to} already exists — refusing to overwrite; resolve by hand`); return; }
1005
- steps.push({ kind: content !== undefined ? "rewrite" : "rename", from, to, note, content });
1006
- };
1007
-
1008
- const cfg = join(scope, "oas-config.yaml");
1009
- if (existsSync(cfg)) {
1010
- const t = transformOasConfigText(readFileSync(cfg, "utf8"), renames);
1011
- try {
1012
- const parsed = parseYamlNested(t.text);
1013
- validateConfigShape(parsed, join(scope, "oats-config.yaml"));
1014
- } catch (e) { errors.push(`transformed oats-config.yaml does not validate: ${e.message}`); }
1015
- addStep(cfg, join(scope, "oats-config.yaml"), t.changes.length ? `${t.changes.length} line change${t.changes.length === 1 ? "" : "s"} (oas: block, capability ids)` : "renamed, content unchanged", t.text);
1016
- }
1017
-
1018
- const lock = join(scope, "oas-lock.json");
1019
- if (existsSync(lock)) {
1020
- try { JSON.parse(readFileSync(lock, "utf8")); }
1021
- catch (e) { errors.push(`oas-lock.json is not valid JSON: ${e.message}`); }
1022
- addStep(lock, join(scope, OATS_LOCK_FILE), "verbatim — entry ids stay oas.* for the chained guided migration to convert");
1023
- }
1024
-
1025
- const store = installedCapabilitiesDir(scope);
1026
- if (existsSync(store)) {
1027
- for (const e of readdirSync(store, { withFileTypes: true })) {
1028
- if (!e.isDirectory() || e.name.startsWith(".")) continue;
1029
- const manifest = join(store, e.name, "oas.json");
1030
- if (existsSync(manifest)) addStep(manifest, join(store, e.name, "oats.json"), "manifest filename");
1031
- }
1032
- }
1033
-
1034
- for (const root of ["agents", "local-agents"]) {
1035
- const rootDir = join(scope, root);
1036
- if (!existsSync(rootDir)) continue;
1037
- for (const e of readdirSync(rootDir, { withFileTypes: true })) {
1038
- if (!e.isDirectory()) continue;
1039
- const owners = join(rootDir, e.name, "soul", ".oas-scaffold-owners.json");
1040
- if (!existsSync(owners)) continue;
1041
- let mapped;
1042
- try {
1043
- const doc = JSON.parse(readFileSync(owners, "utf8"));
1044
- mapped = Object.fromEntries(Object.entries(doc).map(([rel, owner]) => [rel, renames[owner] || owner]));
1045
- } catch (err) { errors.push(`${owners} is not valid JSON: ${err.message}`); continue; }
1046
- addStep(owners, join(rootDir, e.name, "soul", ".oats-scaffold-owners.json"), "owner ids mapped to successors", JSON.stringify(mapped, null, 2) + "\n");
1047
- }
492
+ /** Every executable a manifest can make the kernel run: `commands.*` targets AND `hooks.*.command`
493
+ * targets (hooks run automatically at spawn/retire — the executables an approver most needs to see).
494
+ * → [{ kind: "command"|"hook", name, target }] in canonical order. */
495
+ export function manifestExecutables(manifest) {
496
+ const out = [];
497
+ const commands = plainObject(manifest?.commands) ? manifest.commands : {};
498
+ for (const cmd of Object.keys(commands).sort(byCodepoint)) out.push({ kind: "command", name: cmd, target: commandTarget(commands[cmd]), spec: commands[cmd] });
499
+ const hooks = plainObject(manifest?.hooks) ? manifest.hooks : {};
500
+ for (const hook of Object.keys(hooks).sort(byCodepoint)) {
501
+ // A hook is { command, ... } (schema: required command) or a bare string. A hook object WITHOUT
502
+ // `command` is malformed — it enters the list with spec undefined so executablesDigest refuses it
503
+ // (E_PACKAGE_MANIFEST), never an invisible no-op an approver does not see.
504
+ const spec = plainObject(hooks[hook]) ? hooks[hook].command : hooks[hook];
505
+ out.push({ kind: "hook", name: hook, target: spec === undefined || spec === null ? "" : commandTarget(spec), spec });
1048
506
  }
1049
-
1050
- return { scope: resolve(scope), steps, errors };
507
+ return out;
1051
508
  }
1052
509
 
1053
- /** Apply one scope's --from-oas rename plan under the run journal and return
1054
- * the OPEN journal: the caller chains the guided v1→v2 migration and only
1055
- * then finalizes, so the whole scope conversion is one transaction — any
1056
- * failure in either phase restores the original OAS-named bytes. */
1057
- export function applyFromOasScope(scope, plan) {
1058
- const scopeAbs = resolve(scope);
1059
- // Journal every touched path not already covered by the standard set — an
1060
- // exact repeat or a path nested under a journalled directory would be
1061
- // E_JOURNAL_DUPLICATE_PATH / a redundant double snapshot.
1062
- const covered = (rel) => RUN_JOURNAL_PATHS.some((p) => rel === p || rel.startsWith(`${p}/`));
1063
- const extraPaths = new Set();
1064
- for (const s of plan.steps) {
1065
- for (const p of [s.from, s.to]) {
1066
- const rel = relative(scopeAbs, p);
1067
- if (!rel.startsWith("..") && !covered(rel)) extraPaths.add(rel);
1068
- }
510
+ /**
511
+ * sha256 over every capability manifest's executables' bytes — `commands` targets and
512
+ * `hooks.*.command` targets — in canonical codepoint order (manifest name, kind, entry name).
513
+ * Locale-independent: the same tree digests identically on every machine.
514
+ * Input is a `packageTree` (module header). A target missing from `files` →
515
+ * E_PACKAGE_MANIFEST { capability, command, target }. Empty set → the digest of nothing.
516
+ */
517
+ export function executablesDigest(packageTree) {
518
+ if (!plainObject(packageTree) || !Array.isArray(packageTree.manifests)) {
519
+ throw oatsError("E_PACKAGE_MANIFEST", "executablesDigest expects { manifests: [{ name, manifest, files }] }", { path: "/manifests" });
1069
520
  }
1070
- const journal = beginRunJournal(scopeAbs, { extraPaths: [...extraPaths] });
1071
- try {
1072
- for (const s of plan.steps) {
1073
- if (s.kind === "rewrite") { writeFileSync(s.to, s.content); rmSync(s.from); }
1074
- else renameSync(s.from, s.to);
521
+ const hash = createHash("sha256");
522
+ const manifests = [...packageTree.manifests].sort((a, b) => byCodepoint(String(a.name), String(b.name)));
523
+ for (const { name, manifest, files } of manifests) {
524
+ for (const { kind, name: entry, target, spec } of manifestExecutables(manifest)) {
525
+ const label = kind === "hook" ? `hooks.${entry}.command` : `commands.${entry}`;
526
+ const details = { capability: name, command: entry, kind, target };
527
+ if (typeof spec !== "string") throw oatsError("E_PACKAGE_MANIFEST", `${name}: ${label} must be a string, got ${typeof spec}`, details);
528
+ assertRelPath(`${name} ${label}`, target, details);
529
+ const bytes = files instanceof Map ? files.get(target) : (plainObject(files) && Object.hasOwn(files, target) ? files[target] : undefined);
530
+ if (bytes === undefined) throw oatsError("E_PACKAGE_MANIFEST", `${name}: ${label} targets ${target}, which is not in the package`, details);
531
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
532
+ // Hooks enter the framing under "hooks.<name>" so a hook and a command of the same name never collide.
533
+ hash.update(`${name}\0${kind === "hook" ? `hooks.${entry}` : entry}\0${target}\0${buf.length}\0`);
534
+ hash.update(buf);
535
+ hash.update("\0");
1075
536
  }
1076
- } catch (e) {
1077
- journal.rollback();
1078
- fail(e.code || "E_FROM_OAS_APPLY", `--from-oas rename phase failed at ${scopeAbs} and was rolled back: ${e.message}`);
1079
- }
1080
- return journal;
1081
- }
1082
-
1083
- // ---------- host requirements (structured, consented) ----------
1084
-
1085
- /** Allowlisted install methods. Recipes are data; commands are argv arrays (no shell, no sudo, no auth).
1086
- * Null-prototype: `method.manager` is manifest input, and an ALLOWLIST that
1087
- * answers for inherited `constructor`/`toString` is not an allowlist. */
1088
- export const REQUIREMENT_MANAGERS = {
1089
- __proto__: null,
1090
- "npm-global": {
1091
- scope: "user-level (npm global prefix)",
1092
- plan: (method) => {
1093
- const pkg = String(method.package || "");
1094
- if (!/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~><=-]+)?$/i.test(pkg)) throw new Error(`npm-global package spec is not a plain package name: "${pkg}"`);
1095
- return { argv: ["npm", "install", "-g", pkg], source: `npm registry (${pkg})` };
1096
- },
1097
- },
1098
- brew: {
1099
- scope: "user-level (Homebrew prefix)",
1100
- plan: (method) => {
1101
- const formula = String(method.formula || method.package || "");
1102
- if (!/^[a-z0-9][\w.@/-]*$/i.test(formula)) throw new Error(`brew formula is not a plain formula name: "${formula}"`);
1103
- return { argv: ["brew", "install", formula], source: `Homebrew (${formula})` };
1104
- },
1105
- },
1106
- "download-checksum": {
1107
- scope: "user-level",
1108
- plan: () => { throw new Error("download-with-checksum installs are not implemented yet — use the documented install URL"); },
1109
- },
1110
- };
1111
-
1112
- /** Which runtimes would actually RUN a capability in this scope?
1113
- *
1114
- * A runtime-package requirement must never mutate a host that does not use that
1115
- * runtime: a Claude-only deployment with oats.aweb active is not asked to install
1116
- * a pi package. Capability targeting is per-soul (global/type/soul), so the
1117
- * answer comes from resolving the capability against each known soul and
1118
- * collecting the runtimes of the souls it is actually active for.
1119
- *
1120
- * POLICY when a scope has no souls, or none that the capability targets: the
1121
- * requirement is NOT raised. A fresh deployment cannot know which runtimes its
1122
- * future souls will use, and prompting every host for every runtime is exactly
1123
- * the mutation this exists to avoid. Spawn performs the final, authoritative
1124
- * check against the instance's ACTUAL runtime — which `--runtime` can override
1125
- * after any reconciliation — so a genuinely needed package is still caught
1126
- * there, with a pointed, separately consentable remedy. */
1127
- export function capabilityRuntimeTargets(scope, capId) {
1128
- const requesters = [];
1129
- const runtimes = new Set();
1130
- let souls = 0;
1131
- let root;
1132
- try { root = findRoot(scope); } catch { root = undefined; }
1133
- if (!root) return { runtimes, souls, requesters };
1134
- let list = [];
1135
- try { list = listAgents(root); } catch { list = []; }
1136
- for (const soul of list) {
1137
- souls++;
1138
- try {
1139
- const resolved = resolveOatsConfig(scope, soul.name);
1140
- if (!(resolved.capabilities || []).some((c) => c.id === capId)) continue;
1141
- const runtime = soul.runtime || "pi"; // soul default; spawn may override
1142
- runtimes.add(runtime);
1143
- requesters.push({ soul: soul.name, runtime });
1144
- } catch { /* unresolvable souls are reported by the reconciler */ }
1145
537
  }
1146
- return { runtimes, souls, requesters };
1147
- }
1148
-
1149
- /** Normalize a manifest `requires` entry to the structured form.
1150
- * Legacy shape: { command, why, install: "https://…" }.
1151
- * A present-but-invalid command (empty, null, non-string) returns a typed
1152
- * invalid record so the fail-closed policy sees it — only a fully ABSENT
1153
- * command key is dropped as "not a requirement". */
1154
- export function normalizeRequirement(req) {
1155
- if (!req || typeof req !== "object") return undefined;
1156
- // Runtime-package requirement: satisfied by the RUNTIME's package manager, not
1157
- // by a command on PATH. `runtime` scopes it — a Claude-only deployment is never
1158
- // asked to install a pi package. The identity used for dedup, consent and
1159
- // conflict detection is "<runtime>:<package identity>", version selector
1160
- // stripped, so @latest and a pinned version are one requirement.
1161
- if ("runtime" in req && req.runtime !== undefined) {
1162
- const runtime = typeof req.runtime === "string" ? req.runtime : JSON.stringify(req.runtime);
1163
- const pkg = typeof req.package === "string" ? req.package : req.package === undefined ? "" : JSON.stringify(req.package);
1164
- return {
1165
- kind: "runtime-package", runtime, package: pkg, why: req.why,
1166
- marketplace: req.marketplace,
1167
- command: `${runtime}:${runtimePackageIdentity(runtime, pkg)}`, // identity/consent key
1168
- install: { docs: typeof req.install === "string" ? req.install : req.install?.docs, methods: [] },
1169
- _invalid: !RUNTIME_PACKAGE_MANAGERS[runtime]
1170
- ? `unknown runtime "${runtime}" (known: ${Object.keys(RUNTIME_PACKAGE_MANAGERS).join(", ")})`
1171
- : !safeRuntimePackageSpec(pkg, runtime)
1172
- ? `runtime package spec is not a plain source token for ${runtime}: ${JSON.stringify(pkg)}`
1173
- : req.marketplace !== undefined && !safeRuntimeSourceRef(req.marketplace)
1174
- ? `marketplace is not a plain source reference: ${JSON.stringify(req.marketplace)}`
1175
- : undefined,
1176
- };
1177
- }
1178
- // JSON manifests cannot carry undefined; a programmatic undefined counts as absent.
1179
- if (!("command" in req) || req.command === undefined) return undefined;
1180
- const nonString = typeof req.command !== "string";
1181
- const command = nonString ? JSON.stringify(req.command) : req.command;
1182
- const install = req.install;
1183
- const base = { command, why: req.why, ...(nonString ? { _nonStringCommand: true } : {}) };
1184
- if (typeof install === "string" || install === undefined) {
1185
- return { ...base, install: { docs: typeof install === "string" ? install : undefined, methods: [] } };
1186
- }
1187
- if (typeof install !== "object") return { ...base, install: { methods: [] } };
1188
- const methods = Array.isArray(install.methods) ? install.methods.filter((m) => m && typeof m === "object") : [];
1189
- return { ...base, install: { docs: install.docs, methods } };
538
+ return `sha256-${hash.digest("hex")}`;
1190
539
  }
1191
540
 
1192
- /** Is a command on PATH? (dependency-free `which`). */
1193
- export function commandOnPath(cmd, env = process.env) {
1194
- if (!cmd || /[\\/]/.test(cmd)) return false;
1195
- for (const dir of String(env.PATH || "").split(delimiter)) {
1196
- if (!dir) continue;
1197
- try { const st = statSync(join(dir, cmd)); if (st.isFile() && (st.mode & 0o111)) return true; } catch { /* keep looking */ }
1198
- }
1199
- return false;
541
+ /** Record the executable approval for package `id`. Returns a NEW lock; the input is untouched. */
542
+ export function approve(lock, id, digest, at = new Date().toISOString()) {
543
+ validateLock(lock);
544
+ const entry = lock.packages[id];
545
+ if (!entry) throw oatsError("E_PACKAGE_MISSING", `cannot approve ${JSON.stringify(id)}: not in the lock — run \`oats sync\` first`, { id });
546
+ if (typeof digest !== "string" || !DIGEST_RE.test(digest)) throw oatsError("E_PACKAGE_INTEGRITY", `approval digest for ${id} must be sha256-<hex>`, { id, digest });
547
+ const ms = Date.parse(at);
548
+ if (Number.isNaN(ms)) throw oatsError("E_LOCK_SCHEMA", `approval timestamp for ${id} is not ISO-8601: ${JSON.stringify(at)}`, { id, at });
549
+ const next = clone(lock);
550
+ next.packages[id] = { ...next.packages[id], approved: { executables: digest, at: new Date(ms).toISOString() } };
551
+ return next;
1200
552
  }
1201
553
 
1202
- /** Build the informed-consent install plan for one requirement on this host, or an explanation why none applies.
1203
- * Never uses sudo, shell strings, or authentication. */
1204
- export function requirementInstallPlan(req, { platform = process.platform, context } = {}) {
1205
- const r = normalizeRequirement(req);
1206
- if (!r) return undefined;
1207
- if (r.kind === "runtime-package") {
1208
- // Never build an executable plan for an invalid entry — the fail-closed
1209
- // policy must see it as unconsentable, not as an install recipe.
1210
- if (r._invalid) return { command: r.command, why: r.why, docs: r.install.docs, unavailable: r._invalid };
1211
- const mgr = RUNTIME_PACKAGE_MANAGERS[r.runtime];
1212
- // Some runtimes need more than one command (Claude registers a marketplace
1213
- // before installing). `steps` is the truth; `argv` stays the final command
1214
- // so existing consumers keep working.
1215
- // Same executable the session will launch with — see verifyRuntimePackages.
1216
- const opts = { context, ...(r.runtime === "claude" && context ? { bin: resolveClaudeBinary(context) } : {}) };
1217
- const steps = mgr.steps ? mgr.steps(r.package, req, opts) : [mgr.argv(r.package, req, opts)];
1218
- return {
1219
- command: r.command, why: r.why, docs: r.install.docs,
1220
- manager: r.runtime, steps, argv: steps[steps.length - 1], source: `${r.runtime} package (${r.package})`,
1221
- // Carried so POST-INSTALL verification probes the same executable and
1222
- // context the install ran through. Without it, an install performed via
1223
- // `claude-personal` is verified against the literal `claude` and reported
1224
- // failed (or falsely successful) purely by which account holds the plugin
1225
- // (reviewer-165d668).
1226
- probe: opts,
1227
- scope: mgr.scope, runtime: r.runtime, package: r.package, marketplace: r.marketplace,
1228
- version: r.runtime === "pi" ? (String(r.package).slice(packageSpecIdentity(r.package).length).match(/^@(.+)$/) || [])[1] : undefined,
1229
- };
1230
- }
1231
- const applicable = (r.install.methods || []).filter((m) => !m.platform || m.platform === platform);
1232
- for (const method of applicable) {
1233
- const manager = REQUIREMENT_MANAGERS[method.manager];
1234
- if (!manager) continue; // non-allowlisted methods are ignored, never executed
1235
- try {
1236
- const { argv, source } = manager.plan(method);
1237
- return {
1238
- command: r.command, why: r.why, docs: r.install.docs,
1239
- manager: method.manager, argv, source, scope: manager.scope,
1240
- version: (String(method.package || method.formula || "").match(/.@([^@]+)$/) || [])[1],
1241
- };
1242
- } catch (e) {
1243
- return { command: r.command, why: r.why, docs: r.install.docs, unavailable: e.message };
1244
- }
554
+ /** Which locked package provides capability `capName`? → { id, entry } | null.
555
+ * Two packages providing the same name is ambiguous and fails closed:
556
+ * E_PACKAGE_MISSING { capability, ambiguous: [ids] } — never a silent first-by-id pick. */
557
+ export function packageProviding(lock, capName) {
558
+ if (!plainObject(lock?.packages)) return null;
559
+ const providers = Object.keys(lock.packages).sort().filter((id) => Array.isArray(lock.packages[id]?.capabilities) && lock.packages[id].capabilities.includes(capName));
560
+ if (providers.length === 0) return null;
561
+ if (providers.length > 1) {
562
+ throw oatsError("E_PACKAGE_MISSING", `capability ${JSON.stringify(capName)} is provided by ${providers.length} locked packages (${providers.join(", ")}) — a soul cannot say which; keep one of them in packages:`, { capability: capName, ambiguous: providers });
1245
563
  }
1246
- return { command: r.command, why: r.why, docs: r.install.docs, unavailable: applicable.length ? "no allowlisted install method for this host" : "no install method matches this platform" };
564
+ return { id: providers[0], entry: lock.packages[providers[0]] };
1247
565
  }
1248
566
 
1249
- /** Gate: a requirement's command must be a safe executable basename/CLI token —
1250
- * no path separators, whitespace, leading dash, or shell syntax. Fail closed. */
1251
- export function safeRequirementCommand(cmd) {
1252
- return typeof cmd === "string" && /^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(cmd);
1253
- }
567
+ // ---------- deprecated shims (phase A only; callers are deleted next phase) ----------
1254
568
 
1255
- /** Aggregate missing host requirements across reconciled scopes, only for
1256
- * capabilities activated somewhere in those scopes, deduplicated by command.
1257
- * Returns [{ command, why, docs, plan, requestedBy: [{ capability, scope }],
1258
- * invalid?, conflict? }].
1259
- * Fail-closed identity rules:
1260
- * - a command that is not a safe executable token is flagged { invalid } with
1261
- * NO install plan — it can never be consented or executed;
1262
- * - two active capabilities requesting the SAME command with NON-identical
1263
- * plans produce one deterministic conflict entry ({ conflict: { plans } },
1264
- * provenance-rich, no plan, no consent) — identical plans merge requestedBy. */
1265
- export function aggregateMissingRequirements(scopes, { platform = process.platform, env = process.env, accepted = new Set() } = {}) {
1266
- const byCommand = new Map();
1267
- for (const scope of scopes) {
1268
- // Capabilities targeted by agent-type or soul are INVISIBLE to a
1269
- // soul-less scope resolution, so resolving the scope alone silently skipped
1270
- // their requirements entirely — for host commands as much as for runtime
1271
- // packages. Union the scope-level set with every soul's set, keeping one
1272
- // entry per capability id.
1273
- const active = new Map();
1274
- try { for (const cap of resolveOatsConfig(scope).capabilities || []) active.set(cap.id, cap); }
1275
- catch { continue; /* scope failures are reported by the reconciler */ }
1276
- let root;
1277
- try { root = findRoot(scope); } catch { root = undefined; }
1278
- for (const soul of root ? listAgents(root) : []) {
1279
- try { for (const cap of resolveOatsConfig(scope, soul.name).capabilities || []) if (!active.has(cap.id)) active.set(cap.id, cap); }
1280
- catch { /* unresolvable souls are reported by the reconciler */ }
1281
- }
1282
- for (const cap of active.values()) {
1283
- for (const raw of cap.manifest?.requires || []) {
1284
- const r = normalizeRequirement(raw);
1285
- if (!r) continue;
1286
- if (r.kind === "runtime-package") {
1287
- if (r._invalid) {
1288
- const key = `\u0000invalid:${r.command}`;
1289
- if (!byCommand.has(key)) byCommand.set(key, { kind: "runtime-package", command: r.command, why: r.why, docs: r.install.docs, plan: null, invalid: r._invalid, requestedBy: [] });
1290
- const bad = byCommand.get(key);
1291
- if (!bad.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) bad.requestedBy.push({ capability: cap.id, scope });
1292
- continue;
1293
- }
1294
- // RUNTIME SCOPING: raise this only for a deployment that actually runs
1295
- // the named runtime, or a Claude-only host with oats.aweb active gets
1296
- // prompted for a pi package — the provider-agnostic contract must not
1297
- // mutate a host for an adapter it never uses.
1298
- const targets = capabilityRuntimeTargets(scope, cap.id);
1299
- // EXPLICIT CONSENT OVERRIDES SCOPING. Spawn can be given `--runtime pi`
1300
- // for a soul whose default is claude, and it then emits
1301
- // `oats install --accept-requirement pi:<pkg>`. If scoping also filtered
1302
- // that named requirement out, the remedy we printed would install
1303
- // nothing and the retry would fail identically (reviewer-ad1b9f0).
1304
- // Naming a requirement IS the statement that this host needs it.
1305
- if (!targets.runtimes.has(r.runtime) && !accepted.has(r.command)) continue;
1306
- // Satisfied by the runtime's own package list, not by PATH.
1307
- const probeOpts = { context: scope, ...(r.runtime === "claude" ? { bin: resolveClaudeBinary(scope) } : {}) };
1308
- if (runtimePackageInstalled(r.runtime, r.package, env, probeOpts)) continue;
1309
- const plan = requirementInstallPlan(raw, { platform, context: scope });
1310
- if (!byCommand.has(r.command)) byCommand.set(r.command, { kind: "runtime-package", command: r.command, runtime: r.runtime, package: r.package, why: r.why, docs: r.install.docs, plan, requestedBy: [], _plans: [] });
1311
- const agg = byCommand.get(r.command);
1312
- agg._plans.push({ plan, capability: cap.id, scope });
1313
- if (!agg.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) {
1314
- // Provenance names the souls that pulled it in, so a mixed pi+claude
1315
- // deployment shows ONE deduped requirement explaining why it applies.
1316
- agg.requestedBy.push({ capability: cap.id, scope, souls: targets.requesters.filter((t) => t.runtime === r.runtime).map((t) => t.soul) });
1317
- }
1318
- continue;
1319
- }
1320
- if (r._nonStringCommand || !safeRequirementCommand(r.command)) {
1321
- const key = `\u0000invalid:${r.command}`;
1322
- if (!byCommand.has(key)) byCommand.set(key, { command: r.command, why: r.why, docs: r.install.docs, plan: null, invalid: "requirement command is not a safe executable name (no paths, whitespace, dashes-first, or shell syntax)", requestedBy: [] });
1323
- const bad = byCommand.get(key);
1324
- if (!bad.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) bad.requestedBy.push({ capability: cap.id, scope });
1325
- continue;
1326
- }
1327
- if (commandOnPath(r.command, env)) continue;
1328
- const plan = requirementInstallPlan(raw, { platform });
1329
- if (!byCommand.has(r.command)) {
1330
- byCommand.set(r.command, { command: r.command, why: r.why, docs: r.install.docs, plan, requestedBy: [], _plans: [{ plan, capability: cap.id, scope }] });
1331
- } else {
1332
- const agg = byCommand.get(r.command);
1333
- // Retain EVERY requester's plan so conflict provenance is complete for 3+
1334
- // requesters; the conflict itself derives from the collected set below.
1335
- agg._plans.push({ plan, capability: cap.id, scope });
1336
- }
1337
- const agg = byCommand.get(r.command);
1338
- if (!agg.requestedBy.some((x) => x.capability === cap.id && x.scope === scope)) agg.requestedBy.push({ capability: cap.id, scope });
1339
- }
1340
- }
1341
- }
1342
- // Derive conflicts AFTER collection so provenance covers every requester
1343
- // (3+ capabilities included). Plan identity = the executable argv (or
1344
- // unavailability); non-identical plans for one command are never
1345
- // installable or consentable.
1346
- // steps is authoritative: two capabilities can require the same plugin while
1347
- // registering its marketplace from DIFFERENT sources, which collapses to one
1348
- // requirement if only the final argv is compared (reviewer-6f1bb9c).
1349
- const planKey = (p) => JSON.stringify(p?.steps || (p?.argv ? [p.argv] : null) || p?.unavailable || null);
1350
- for (const agg of byCommand.values()) {
1351
- if (!agg._plans) continue;
1352
- const keys = new Set(agg._plans.map((x) => planKey(x.plan)));
1353
- if (keys.size > 1) {
1354
- agg.conflict = { plans: agg._plans.map((x) => ({ capability: x.capability, scope: x.scope, argv: x.plan?.argv || null, steps: x.plan?.steps || null, unavailable: x.plan?.unavailable || null })) };
1355
- agg.plan = null;
1356
- }
1357
- }
1358
- // Canonical string sort key: commands may be JSON.stringify'd non-strings.
1359
- return [...byCommand.values()].map(({ _plans, ...rest }) => rest).sort((a, b) => String(a.command).localeCompare(String(b.command)));
1360
- }
569
+ const removed = (name) => function removedByWorkspaceModel() {
570
+ throw oatsError("E_REMOVED", `${name} was removed by the workspace model; see ${CONTRACT_DOC}`, { name, contract: CONTRACT_DOC });
571
+ };
1361
572
 
1362
- /** Execute one consented install plan (argv, no shell) and verify the command lands on PATH. */
1363
- export function runRequirementInstall(plan, { env = process.env, stdio = "inherit" } = {}) {
1364
- if (!plan || !plan.argv) throw new Error(`no executable install plan for "${plan?.command}"`);
1365
- for (const step of plan.steps?.length ? plan.steps : [plan.argv]) {
1366
- if (step?.length) execFileSync(step[0], step.slice(1), { stdio, env });
1367
- }
1368
- // Verify what the requirement actually promised. A runtime package never
1369
- // lands on PATH, so verifying it there would report every successful install
1370
- // as a failure.
1371
- const onPath = plan.runtime
1372
- ? runtimePackageInstalled(plan.runtime, plan.package, env, plan.probe || {})
1373
- : commandOnPath(plan.command, env);
1374
- return { command: plan.command, installed: true, onPath };
1375
- }
573
+ export const aggregateMissingRequirements = removed("aggregateMissingRequirements");
574
+ export const applyConfigMerge = removed("applyConfigMerge");
575
+ export const applyFromOasScope = removed("applyFromOasScope");
576
+ export const beginRunJournal = removed("beginRunJournal");
577
+ export const capabilityRuntimeTargets = removed("capabilityRuntimeTargets");
578
+ export const commandOnPath = removed("commandOnPath");
579
+ export const discoverMigrationScopes = removed("discoverMigrationScopes");
580
+ export const discoverOasScopes = removed("discoverOasScopes");
581
+ export const discoverWorkspaceScopes = removed("discoverWorkspaceScopes");
582
+ export const lockedPackageCapabilities = removed("lockedPackageCapabilities");
583
+ export const normalizeRequirement = removed("normalizeRequirement");
584
+ export const oasRenameMap = removed("oasRenameMap");
585
+ export const packageSpecIdentity = removed("packageSpecIdentity");
586
+ export const planConfigMerge = removed("planConfigMerge");
587
+ export const planFromOasScope = removed("planFromOasScope");
588
+ export const readAdoptedTemplate = removed("readAdoptedTemplate");
589
+ export const requirementInstallPlan = removed("requirementInstallPlan");
590
+ export const runRequirementInstall = removed("runRequirementInstall");
591
+ export const runtimePackageInstalled = removed("runtimePackageInstalled");
592
+ export const runtimePackageStatus = removed("runtimePackageStatus");
593
+ export const selectConfigTemplate = removed("selectConfigTemplate");
594
+ export const splitConfigLines = removed("splitConfigLines");
595
+ export const transformOasConfigText = removed("transformOasConfigText");
596
+ export const validateConfigTemplate = removed("validateConfigTemplate");
597
+ export const writeAdoptedTemplate = removed("writeAdoptedTemplate");
598
+ export const adoptedTemplateDir = removed("adoptedTemplateDir");
599
+ /** Was a constant table; any property access throws E_REMOVED. */
600
+ export const REQUIREMENT_MANAGERS = new Proxy(Object.freeze({}), {
601
+ get(_t, prop) { if (typeof prop === "symbol" || prop === "then") return undefined; removed("REQUIREMENT_MANAGERS")(); },
602
+ has() { removed("REQUIREMENT_MANAGERS")(); },
603
+ });