@awebai/oats 0.22.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,1375 @@
1
+ /**
2
+ * OATS distribution packages — WS2 policy layer (config bootstrap and workspace
3
+ * reconciliation) over the package ENGINE in lib/core.mjs.
4
+ *
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).
12
+ *
13
+ * Runtime-neutral and dependency-free, like lib/core.mjs.
14
+ */
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";
19
+ 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";
22
+ 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
+ }
42
+
43
+ const AGENT_TYPE_RE = /^[a-z][a-z0-9-]*$/;
44
+
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.
50
+
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";
55
+
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._-]*$/;
59
+
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
+ };
68
+
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. */
82
+ export function assertNoSymlinkedParents(scopeDir, leafDir, what) {
83
+ const base = resolve(scopeDir);
84
+ const leaf = resolve(leafDir);
85
+ if (!isInside(base, leaf)) fail("E_ADOPTED_PATH_UNSAFE", `${what} resolves outside the scope: ${leaf}`);
86
+ const rel = relative(base, leaf);
87
+ 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
+ for (const part of rel ? rel.split(sep) : []) {
91
+ cur = join(cur, part);
92
+ let st;
93
+ try { st = lstatSync(cur); } catch { return; } // absent from here down: this run creates it
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}`);
184
+ }
185
+ }
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
+ }
201
+
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. */
205
+ export function writeFileAtomic(file, contents) {
206
+ mkdirSync(dirname(file), { recursive: true });
207
+ const tmp = join(dirname(file), `.${basename(file)}.oats-tmp-${process.pid}`);
208
+ try {
209
+ writeFileSync(tmp, contents);
210
+ renameSync(tmp, file);
211
+ } finally { rmSync(tmp, { force: true }); }
212
+ }
213
+
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 };
255
+ }
256
+
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
+ }
284
+
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.
300
+
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;
305
+
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; }
316
+ }
317
+ if (start < s.length) lines.push(s.slice(start));
318
+ return lines;
319
+ }
320
+
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]);
332
+ }
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;
349
+ }
350
+
351
+ /** Line-count delta a region introduces on the non-base side. */
352
+ const regionDelta = (r) => (r.bEnd - r.bStart) - (r.aEnd - r.aStart);
353
+
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")}`;
358
+ }
359
+
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);
381
+
382
+ const localRegions = changeRegions(base, local);
383
+ const templateRegions = changeRegions(base, template);
384
+
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);
393
+
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
+ }
403
+ }
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
+ });
441
+ }
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
+ }
455
+
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"})`);
479
+ }
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`);
486
+ }
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;
491
+ }
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;
510
+ }
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 };
554
+ }
555
+
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}`);
578
+ }
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`);
580
+ }
581
+ }
582
+
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;
599
+ }
600
+
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;
622
+ }
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);
627
+ }
628
+
629
+ /** Open a run-level rollback journal over one scope.
630
+ *
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.
635
+ *
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";
702
+
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 });
719
+ }
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); }
729
+ }
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 });
739
+ }
740
+ }
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
+
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 };
832
+ }
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
+
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;
925
+ }
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
+
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 }));
949
+ }
950
+
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
+ }
963
+
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 };
985
+ }
986
+
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
+ }
1048
+ }
1049
+
1050
+ return { scope: resolve(scope), steps, errors };
1051
+ }
1052
+
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
+ }
1069
+ }
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);
1075
+ }
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
+ }
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 } };
1190
+ }
1191
+
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;
1200
+ }
1201
+
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
+ }
1245
+ }
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" };
1247
+ }
1248
+
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
+ }
1254
+
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
+ }
1361
+
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
+ }