@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
package/bin/oats.mjs ADDED
@@ -0,0 +1,3294 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * oats — the OATS command line.
4
+ *
5
+ * oats doctor [dir] [--json] show the resolved config with origins
6
+ * oats install <name|url|path> [...] acquire + exact-lock a capability
7
+ * oats trust <capability> approve locked executable surfaces
8
+ * oats use <capability> [...] activate/exclude for global/group/soul
9
+ * oats init [--raw] create an oats-config.yaml here
10
+ *
11
+ * `use` and `init` edit the oats-config.yaml at the detected level root:
12
+ * cwd is your home dir → laptop; cwd has .git → repo; otherwise → workspace.
13
+ * The kernel resolves per-key closest-wins from wherever agents actually run,
14
+ * so binding at a level scopes the capability to everything under it.
15
+ */
16
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
+ import { execFileSync, spawnSync } from "node:child_process";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { basename, dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { enableTmuxMouse, tmuxConfigPath, tmuxMouseEnabled } from "../lib/tmux-config.mjs";
22
+ import {
23
+ LAYERS, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain,
24
+ acquireCapability, restoreCapabilities, marketplaceCapabilities,
25
+ capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath,
26
+ readCapabilityLocks, writeCapabilityLock,
27
+ parsePackageSource, inspectGitSourceRoot, acquirePackage, restorePackages, listInstalledPackages, readPackageLocks, readLockedConfigTemplates,
28
+ officialCapabilityPackage, officialPackageCatalog,
29
+ approveCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
30
+ packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
31
+ resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
32
+ findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
33
+ ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
34
+ spawnInstance, retireInstance, upsertLocalAgent, defaultRepo, RELATIONS,
35
+ } from "../lib/core.mjs";
36
+ import {
37
+ aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
38
+ adoptedTemplateDir, applyConfigMerge, lockedPackageCapabilities, planConfigMerge, readAdoptedTemplate, requirementInstallPlan,
39
+ assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
40
+ runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
41
+ } from "../lib/packages.mjs";
42
+
43
+ const args = process.argv.slice(2);
44
+ const cmd = args[0];
45
+ const HELP_WORDS = new Set(["help", "--help", "-h"]);
46
+ const flag = (name) => {
47
+ const i = args.indexOf(`--${name}`);
48
+ return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
49
+ };
50
+ const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
51
+ /** Resolve the --dir flag with central validation: a value-taking flag given
52
+ * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
53
+ * uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
54
+ function dirFlag() {
55
+ const v = flag("dir");
56
+ if (v === undefined) return resolve(process.cwd());
57
+ if (v === true || !String(v).trim()) {
58
+ const msg = "--dir needs a directory path";
59
+ if (JSON_MODE) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_BAD_ARGS", message: msg } })); process.exit(1); }
60
+ die(msg);
61
+ }
62
+ return resolve(String(v));
63
+ }
64
+ // Desktop CLI API v1 (JSON mode): every `--json` failure is EXACTLY ONE JSON
65
+ // object on stdout — { schemaVersion: 1, ok: false, error: { code, message } } —
66
+ // with a nonzero exit; progress prose goes to stderr, never stdout.
67
+ const JSON_MODE = args.includes("--json");
68
+ // Canonical absolute path of this CLI executable — the versioned OATS_CLI_BIN
69
+ // env contract for dispatched package commands (never resolved via PATH).
70
+ const CLI_BIN = realpathSync(fileURLToPath(import.meta.url));
71
+ const jsonFail = (code, message) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message) } })); process.exit(1); };
72
+ const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result })); };
73
+
74
+ /** Level of a directory: laptop (home), repo (.git), else workspace. */
75
+ function levelOf(dir) {
76
+ const d = resolve(dir);
77
+ if (d === homedir()) return "laptop";
78
+ if (existsSync(join(d, ".git"))) return "repo";
79
+ return "workspace";
80
+ }
81
+
82
+ function shortPath(p) {
83
+ if (!p) return p;
84
+ const home = homedir();
85
+ return p.startsWith(home) ? "~" + p.slice(home.length) : p;
86
+ }
87
+
88
+ /** Shell-safe single-quoting for copyable human commands (paths may contain spaces/metacharacters). */
89
+ function shellQuote(s) {
90
+ return /^[A-Za-z0-9._/~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
91
+ }
92
+
93
+ /** The scaffolded `name:` value — the target directory's basename — held to the
94
+ * SAME write refusal as every other value this CLI renders into a config line.
95
+ *
96
+ * A basename is filesystem input, not a literal: a directory whose name embeds
97
+ * a newline turned one scaffolded `name:` line into arbitrary top-level config
98
+ * blocks (a live `team:` block smuggled through `oats init`), and a `#`-leading
99
+ * basename wrote a value that reads back as an empty map. Refusing names the
100
+ * offending basename and writes nothing — the operator renames the directory. */
101
+ function scaffoldConfigName(dir) {
102
+ return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
103
+ }
104
+
105
+ function offerTmuxMouseScrolling() {
106
+ if (args.includes("--no-tmux-mouse")) return;
107
+ const configPath = tmuxConfigPath();
108
+ const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
109
+ if (tmuxMouseEnabled(current)) return;
110
+
111
+ let accepted = args.includes("--tmux-mouse");
112
+ if (!accepted) {
113
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
114
+ process.stdout.write("Enable normal mouse/trackpad scrolling in tmux agent windows? [Y/n] ");
115
+ const buffer = Buffer.alloc(256);
116
+ const length = readSync(process.stdin.fd, buffer, 0, buffer.length);
117
+ accepted = !buffer.subarray(0, length).toString("utf8").trim().toLowerCase().startsWith("n");
118
+ }
119
+ if (!accepted) return;
120
+
121
+ const result = enableTmuxMouse(configPath);
122
+ console.log(`Enabled tmux mouse scrolling in ${shortPath(result.configPath)}${result.reloaded ? " (reloaded)" : ""}`);
123
+ }
124
+
125
+ // ---------- doctor ----------
126
+ /** Doctor must diagnose, not crash: a stale activation of a retired
127
+ * capability fails config resolution — surface the cleanup instruction
128
+ * cleanly (text or JSON) instead of an uncaught stack trace. */
129
+ function resolveForDoctor(ctx, soulName, { json } = {}) {
130
+ try { return resolveOatsConfig(ctx, soulName); }
131
+ catch (e) {
132
+ // Doctor is THE diagnosis surface: it alone catches the typed fail-closed
133
+ // invalid-lock error and continues to render actionable state.
134
+ if (e.code === "invalid-lock") {
135
+ const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
136
+ if (json) { console.log(JSON.stringify({ context: ctx, error: { code: "invalid-lock", message: e.message, provenance: e.provenance || null } }, null, 2)); process.exit(1); }
137
+ console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
138
+ console.log(`ERROR: ${e.message} [invalid-lock]`);
139
+ if (prov?.file) console.log(` fix or remove the offending entry in ${shortPath(prov.file)} — the lock is never auto-repaired; all package operations fail closed until it is valid`);
140
+ process.exit(0); // doctor DIAGNOSED successfully; the lock is the problem
141
+ }
142
+ const retiredId = Object.keys(RETIRED_CAPABILITIES).find((id) => String(e.message).includes(`"${id}"`) && String(e.message).includes("retired"));
143
+ if (!retiredId) throw e;
144
+ if (json) { console.log(JSON.stringify({ schemaVersion: 1, context: ctx, error: e.message, retired: [retiredId] }, null, 2)); process.exit(1); }
145
+ die(`${e.message}`);
146
+ }
147
+ }
148
+ function doctorComposition(ctx, soulName) {
149
+ if (!soulName) return undefined;
150
+ const root = findRoot(ctx);
151
+ const agent = root && findAgent(root, soulName);
152
+ if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
153
+ return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
154
+ }
155
+ /** WS2 package-layer doctor data — the ONE source for both human and --json
156
+ * doctor output: lock v2 packages, adopted-profile provenance, available-but-
157
+ * unapplied profiles, and missing host requirements with structured plans. */
158
+ /** Guided-upgrade readiness for the legacy official capabilities visible from a
159
+ * scope (release contract §4): which legacy `marketplace:` locks exist, which
160
+ * official package supplies each one, and whether this release's catalog can
161
+ * map them all yet. `null` when there is no legacy official state at all. */
162
+ function officialMigrationState(legacyLocks, { teamScope, ctx }) {
163
+ const capabilities = [];
164
+ for (const l of legacyLocks) {
165
+ for (const [id, entry] of Object.entries(l.capabilities || {})) {
166
+ if (typeof entry?.source !== "string" || !entry.source.startsWith("marketplace:")) continue;
167
+ let m;
168
+ try { m = officialCapabilityPackage(id); }
169
+ catch (e) { return { status: "unavailable", capabilities: [], command: null, reason: `the official package catalog is unreadable: ${e.message}` }; }
170
+ capabilities.push({ capability: id, package: m.package, via: m.via, available: m.available, file: l.file, level: l.level });
171
+ }
172
+ }
173
+ if (!capabilities.length) return null;
174
+ const boundary = teamScope || ctx;
175
+ const command = `oats migrate --official --recursive --dir ${shellQuote(boundary)}`;
176
+ const missing = capabilities.filter((c) => !c.available);
177
+ return missing.length
178
+ ? { status: "unavailable", capabilities, command: null, reason: `no official package mapping yet for ${missing.map((c) => c.capability).join(", ")} — this release keeps the legacy capabilities working; migration becomes available when the catalog publishes them` }
179
+ : { status: "ready", capabilities, command, reason: null };
180
+ }
181
+
182
+ /** The health of ONE materialized capability, against the rows it was projected
183
+ * from. Shared by doctor and list so both name the same states with the same
184
+ * codes — and so the `.oats-installation.json` provenance is checked in BOTH,
185
+ * not only deep inside trust resolution where it surfaces as a bare "untrusted".
186
+ *
187
+ * Order matters: a missing artifact cannot be hashed, drifted bytes make an
188
+ * approval meaningless (so trust is not ALSO reported), and provenance is only
189
+ * worth reading once the bytes are the locked ones. */
190
+ /** The lock rows AT one level. Never the merged maps: those resolve each
191
+ * identity independently, so an outer scope's capability can be paired with a
192
+ * nearer scope's package of the same id — a provider that never exported it. */
193
+ const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
194
+
195
+ function capabilityHealth(level, cap, capRow, pkgRow) {
196
+ const dir = installedCapabilityDir(level, cap.id);
197
+ if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats install\` to re-materialize it` };
198
+ let integrity;
199
+ try { integrity = capabilityArtifactIntegrity(dir); }
200
+ catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
201
+ if (integrity !== cap.integrity) {
202
+ return { status: "drifted", code: "integrity-drift", dir, integrity, detail: `capability ${cap.id}: artifact integrity drift — installed ${integrity}, locked ${cap.integrity}; its executable approval is invalid` };
203
+ }
204
+ // The artifact's own provenance and the lock must tell the SAME story before
205
+ // either is believed. Neither silently wins; the disagreement is the finding.
206
+ if (capRow && pkgRow) {
207
+ try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
208
+ catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
209
+ }
210
+ const executable = Object.keys(cap.manifest?.commands || {}).length
211
+ || Object.keys(cap.manifest?.hooks || {}).length
212
+ || (cap.manifest?.environment?.length || 0);
213
+ if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
214
+ return { status: "ok", code: null, dir, integrity, detail: null };
215
+ }
216
+
217
+ function doctorPackagesData(ctx, chain, { teamScope } = {}) {
218
+ // reviewer-455ba15 fix 4: the ENGINE diagnostics the human doctor renders
219
+ // (invalid locks, missing artifacts, integrity/runtime-closure drift,
220
+ // capability-list mismatches, untrusted surfaces, legacy-lock states)
221
+ // are computed HERE so doctor --json exposes them structurally — machine
222
+ // consumers see every state the human report calls broken. Fail-closed
223
+ // reads are diagnosed, never consumed as data and never swallowed.
224
+ let pkgLocks = { packages: {}, legacy: [] };
225
+ let installedPkgs = [];
226
+ let lockBroken = null;
227
+ try { pkgLocks = readPackageLocks(ctx); installedPkgs = listInstalledPackages(ctx); }
228
+ catch (e) {
229
+ const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
230
+ lockBroken = { code: e.code || "invalid-lock", message: String(e.message || e), file: prov?.file || null, provenance: e.provenance || null };
231
+ }
232
+ const packages = [];
233
+ for (const p of installedPkgs) {
234
+ // SCOPE-EXACT, like `oats list` and `oats trust` above: `p` was derived from
235
+ // the lock AT `p.level`, and its artifacts live under that scope, so only
236
+ // that scope's rows can judge them. The MERGED maps resolve each identity
237
+ // closest-scope-first — right for "which capability is active here", wrong
238
+ // here — so a chain holding one package id at two scopes (a direct
239
+ // acquisition outside, the same id pulled in by a dependency closure
240
+ // inside, each with its own source spelling) would compare an outer
241
+ // artifact's provenance against the inner row it was never projected from
242
+ // and report a self-consistent pair as invalid-lock.
243
+ const rows = levelRows(pkgLocks, p.level);
244
+ const lock = Object.hasOwn(rows.packages, p.package) ? { ...rows.packages[p.package], _file: join(p.level, OATS_LOCK_FILE), _level: p.level } : undefined;
245
+ const problems = [];
246
+ if (!lock) problems.push({ code: "invalid-lock", detail: "installed but not locked — reacquire it" });
247
+ else {
248
+ // There is no persistent package root to hash: the package row exact-locks
249
+ // a remote payload, and the only bytes on disk are the flat capability
250
+ // artifacts. So every health check is per capability, against the artifact
251
+ // integrity the engine recorded for it.
252
+ for (const c of p.capabilities) {
253
+ const h = capabilityHealth(p.level, c, Object.hasOwn(rows.capabilities, c.id) ? rows.capabilities[c.id] : undefined, lock);
254
+ if (h.status !== "ok") problems.push({ code: h.code, detail: h.detail });
255
+ }
256
+ }
257
+ packages.push({
258
+ id: p.package, version: p.version || null, level: p.level, source: lock?.source || null,
259
+ path: lock?.path || null, commit: lock?.commit || null, capabilities: p.capabilities.map((c) => c.id),
260
+ dependencies: lock?.dependencies || [],
261
+ status: problems.length ? "broken" : "ok", problems,
262
+ });
263
+ }
264
+ for (const [id, lock] of Object.entries(pkgLocks.packages)) {
265
+ if (!installedPkgs.some((p) => p.package === id)) {
266
+ // Capability rows carry the provider back-reference — the package row has
267
+ // no capability list to read any more.
268
+ const provided = Object.entries(pkgLocks.capabilities).filter(([, c]) => c.package === id).map(([capId]) => capId);
269
+ packages.push({ id, version: lock.version || null, level: lock._level, source: lock.source || null, path: lock.path || null, commit: lock.commit || null, capabilities: provided, dependencies: lock.dependencies || [], status: "broken", problems: [{ code: "missing-locked-package", detail: `locked in ${lock._file} but not installed — run oats install` }] });
270
+ }
271
+ }
272
+ // Supported v1 scopes — empty or not — are pending an explicit LOCK-FORMAT
273
+ // migration (maintainer ruling). There is no second view beside this one:
274
+ // migration never produces residue, and the superseded transitional v2 shape
275
+ // is rejected wholesale by the strict reader, so it reaches doctor as the
276
+ // single `lockError` diagnosis above rather than as partially parsed entries.
277
+ const legacyLockFiles = pkgLocks.legacy
278
+ .map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion ?? 1, empty: !Object.keys(l.capabilities || {}).length, status: "pending-format-migration", action: `oats migrate --dir ${l.level}` }));
279
+ // Adoption provenance now comes from the visible, commit-safe adopted base —
280
+ // not from a provenance comment the local config could lose to an edit.
281
+ const adoptedTemplates = [];
282
+ for (const cfg of chain) {
283
+ const level = dirname(cfg._file);
284
+ let adopted;
285
+ try { adopted = readAdoptedTemplate(level); }
286
+ catch (e) { adoptedTemplates.push({ level, file: cfg._file, status: "broken", code: e.code || "E_ADOPTION_INVALID", detail: e.message }); continue; }
287
+ if (!adopted) continue;
288
+ let localChanges = null;
289
+ try { localChanges = readFileSync(cfg._file, "utf8") !== adopted.baseText; } catch { /* unreadable config is reported elsewhere */ }
290
+ adoptedTemplates.push({
291
+ level, file: cfg._file, package: adopted.package, template: adopted.template,
292
+ base: adopted.baseFile, source: adopted.metadata?.source || null,
293
+ version: adopted.metadata?.version || null, commit: adopted.metadata?.commit || null,
294
+ hash: adopted.metadata?.hash || null, localChanges, status: "ok",
295
+ });
296
+ }
297
+ // NOTE: doctor deliberately does NOT enumerate templates a package exports but
298
+ // nobody adopted. In the materialized model there is no package root on disk,
299
+ // so that list only exists behind a network fetch of the locked source — and
300
+ // a diagnostic command must never go to the network to render a hint.
301
+ const missingHostRequirements = aggregateMissingRequirements([ctx]).map((req) => ({
302
+ command: req.command, why: req.why || null, docs: req.docs || null,
303
+ requestedBy: req.requestedBy,
304
+ plan: req.plan && !req.plan.unavailable
305
+ ? { manager: req.plan.manager, argv: req.plan.argv, steps: req.plan.steps || [req.plan.argv], source: req.plan.source, version: req.plan.version || null, scope: req.plan.scope }
306
+ : null,
307
+ invalid: req.invalid || null,
308
+ conflict: req.conflict || null,
309
+ unavailable: req.plan?.unavailable || null,
310
+ // Context-complete + shell-safe: the copyable command pins the resolved
311
+ // scope with --dir so it cannot target another deployment from a
312
+ // different cwd. Command and ctx are validated/quoted for safe copying.
313
+ consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
314
+ ? `oats install --accept-requirement ${req.command} --dir ${shellQuote(ctx)}`
315
+ : null,
316
+ }));
317
+ return { lockError: lockBroken, packages, legacyLockFiles, adoptedTemplates, missingHostRequirements, officialMigration: officialMigrationState(pkgLocks.legacy, { teamScope, ctx }) };
318
+ }
319
+
320
+ function doctorJson(dir) {
321
+ const ctx = resolve(dir || process.cwd());
322
+ const soulName = flag("soul");
323
+ const r = resolveForDoctor(ctx, soulName, { json: true });
324
+ const mans = capabilityManifests(ctx);
325
+ const composition = doctorComposition(ctx, soulName);
326
+ const chain = configChain(ctx);
327
+ const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
328
+ const oasScopes = detectOasScopes(ctx);
329
+ console.log(JSON.stringify({
330
+ schemaVersion: 1,
331
+ context: ctx,
332
+ team: r.team || null,
333
+ chain: r.chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })),
334
+ oasScopes,
335
+ oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
336
+ layers: Object.fromEntries(LAYERS.map((l) => [l, r.layers[l] ? {
337
+ integration: r.layers[l].id, level: r.layers[l].level, inject: r.layers[l].inject,
338
+ skills: [...(Array.isArray(r.layers[l].skills) ? r.layers[l].skills : (r.layers[l].skills ? [r.layers[l].skills] : []))],
339
+ hooks: Object.keys(r.layers[l].hooks || {}), missingRequires: r.layers[l].missingRequires,
340
+ provenance: r.provenance[l],
341
+ } : { provenance: r.provenance[l] || null }])),
342
+ kernelInjection: r.kernelInjection,
343
+ injects: r.injects,
344
+ capabilities: r.capabilities.map((c) => ({ id: c.id, layer: c.layer, command: c.command, origin: c.origin, provenance: c.provenance, settings: c.settings, skills: c.skills, inject: c.inject, hooks: Object.keys(c.hooks || {}), trust: c.trust })),
345
+ acquired: Object.fromEntries(Object.entries(mans).map(([n, m]) => [n, { layer: m.layer, command: m.command, version: m.version, dir: m._dir, origin: m._origin, description: m.description }])),
346
+ retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); } catch { return []; } })()
347
+ .filter(([id]) => retiredCapabilityReason(id))
348
+ .map(([id, lock]) => ({ id, file: lock._file, reason: retiredCapabilityReason(id) })),
349
+ retiredArtifacts: Object.entries(mans)
350
+ .filter(([id]) => retiredCapabilityReason(id))
351
+ .map(([id, m]) => ({ id, dir: m._dir, origin: m._origin, reason: retiredCapabilityReason(id) })),
352
+ // Shared WS2+engine package payload (fix 4: human and JSON doctor derive
353
+ // from ONE computation; fail-closed reads are diagnosed via lockError —
354
+ // doctorPackagesData carries the engine's legacy-lock shapes).
355
+ packages: pkg.packages,
356
+ lockError: pkg.lockError,
357
+ legacyLockFiles: pkg.legacyLockFiles,
358
+ officialMigration: pkg.officialMigration,
359
+ adoptedTemplates: pkg.adoptedTemplates,
360
+ missingHostRequirements: pkg.missingHostRequirements,
361
+ composedInstructions: composition?.text,
362
+ instructionBlocks: composition?.blocks,
363
+ }, null, 2));
364
+ }
365
+
366
+ function doctor(dir) {
367
+ const ctx = resolve(dir || process.cwd());
368
+ const soulName = flag("soul");
369
+ const chain = configChain(ctx);
370
+ const r = resolveForDoctor(ctx, soulName);
371
+ console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
372
+
373
+ // Kernel/bridge version skew (published in lockstep from one tag).
374
+ const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
375
+ if (existsSync(piPkgFile)) {
376
+ const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
377
+ if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
378
+ }
379
+
380
+ console.log("Config chain (closest first):");
381
+ if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
382
+ for (const c of chain) {
383
+ console.log(` ${shortPath(c._file)} [${levelOf(c._level)}]`);
384
+ }
385
+
386
+ // An empty-looking chain over oas-* files is not an empty scope: it is a
387
+ // pre-rename OAS deployment this kernel cannot read (aweb-abfy.1).
388
+ const oasScopes = detectOasScopes(ctx);
389
+ if (oasScopes.length) {
390
+ console.log("");
391
+ for (const f of oasScopes) console.log(`UN-MIGRATED OAS SCOPE: ${shortPath(f.dir)} (${f.files.join(", ")})`);
392
+ console.log(` ${OAS_SCOPE_REMEDY}`);
393
+ }
394
+
395
+ if (r.team) console.log(`\nTeam: ${r.team.name}${r.team.id ? ` (id: ${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]`);
396
+
397
+ console.log("\nLayers:");
398
+ for (const layer of LAYERS) {
399
+ const l = r.layers[layer];
400
+ const prov = r.provenance[layer];
401
+ if (!prov) { console.log(` ${layer.padEnd(10)} (unresolved — no declaration in chain)`); continue; }
402
+ if (!l) { console.log(` ${layer.padEnd(10)} none [${prov}]`); continue; }
403
+ console.log(` ${layer.padEnd(10)} ${l.id} [${prov}]`);
404
+ if (l.inject) console.log(` inject: ${shortPath(l.inject)}`);
405
+ const skills = Array.isArray(l.skills) ? l.skills : (l.skills ? [l.skills] : []);
406
+ if (skills.length) console.log(` skills: ${skills.map(shortPath).join(", ")}`);
407
+ const hooks = Object.keys(l.hooks || {});
408
+ if (hooks.length) console.log(` hooks: ${hooks.join(", ")}`);
409
+ for (const miss of l.missingRequires || []) {
410
+ console.log(` MISSING REQUIREMENT: ${miss.command} — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
411
+ }
412
+ }
413
+
414
+ console.log("\nKernel injection:");
415
+ console.log(` oats: ${r.kernelInjection?.inject ? shortPath(r.kernelInjection.inject) : "none"} [${r.kernelInjection?.provenance || "default"}]`);
416
+
417
+ console.log("\nUnconditional injections (outermost→innermost):");
418
+ if (r.injects.length === 0) console.log(" (none)");
419
+ for (const inj of r.injects) console.log(` ${inj.source}: ${shortPath(inj.file)}`);
420
+
421
+ for (const mode of ["worktree", "checkout", "attached", "workspace"]) {
422
+ const wm = resolveWorkMode(ctx, mode);
423
+ console.log(`\nWork mode ${mode}: inject ${wm.inject ? shortPath(wm.inject) : "none"}${wm.setup ? `, setup ${shortPath(wm.setup)}` : ""}`);
424
+ }
425
+
426
+ console.log("\nActive capabilities:");
427
+ if (!r.capabilities.length) console.log(" (none)");
428
+ for (const cap of r.capabilities) {
429
+ console.log(` ${cap.id}${cap.layer ? ` layer: ${cap.layer}` : ""} [${cap.provenance.join(" + ")}]`);
430
+ console.log(` trust: ${cap.trust.trusted ? "approved" : `BLOCKED (${cap.trust.reason})`}`);
431
+ if (cap.inject) console.log(` inject: ${shortPath(cap.inject)}`);
432
+ if (cap.skills.length) console.log(` skills: ${cap.skills.map(shortPath).join(", ")}`);
433
+ }
434
+ console.log("\nAcquired capability packages:");
435
+ for (const [name, m] of Object.entries(capabilityManifests(ctx))) {
436
+ const missing = capabilityMissingRequires(name, ctx);
437
+ console.log(` ${name.padEnd(16)} layer: ${(m.layer || "additive").padEnd(10)} origin: ${m._origin}${missing.length ? ` (missing: ${missing.map((x) => x.command).join(", ")})` : ""}`);
438
+ const retiredReason = retiredCapabilityReason(name);
439
+ if (retiredReason) {
440
+ const installed = String(m._origin).startsWith("installed:");
441
+ console.log(` WARNING: artifact of a retired capability — ${retiredReason}${installed ? `; also delete ${shortPath(m._dir)}` : ` (origin ${m._origin}: remove its declaration; the source tree at ${shortPath(m._dir)} is yours to keep or drop)`}`);
442
+ }
443
+ }
444
+ // readCapabilityLocks fails closed on invalid legacy entries — doctor is the
445
+ // diagnosis surface, so catch the typed error and render it (never using the data).
446
+ let locks = {};
447
+ try { locks = readCapabilityLocks(ctx); }
448
+ catch (e) {
449
+ if (e.code !== "invalid-lock") throw e;
450
+ const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
451
+ console.log(` ERROR: ${e.message} [invalid-lock]`);
452
+ if (prov?.file) console.log(` fix or remove the entry in ${shortPath(prov.file)} — never auto-repaired; legacy trust/restore fail closed until it is valid`);
453
+ }
454
+ const mans = capabilityManifests(ctx);
455
+ for (const [id, lock] of Object.entries(locks)) {
456
+ const retiredReason = retiredCapabilityReason(id);
457
+ if (retiredReason) { console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but ${retiredReason}`); continue; }
458
+ if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats install\``);
459
+ }
460
+ for (const [id, m] of Object.entries(mans)) {
461
+ if (!String(m._origin).startsWith("installed:")) continue;
462
+ // SCOPE-EXACT on the v2 side. `m._capabilityLock` is the row from the
463
+ // artifact's OWN scope's lock (capabilityManifests annotates it there), and
464
+ // that is the only row that can lock this artifact: the merged chain would
465
+ // let an outer scope's lock — or a lock-only ancestor with no config at all
466
+ // — silence an unlocked inner copy that WINS discovery precedence and
467
+ // activates. The legacy arm stays chain-merged: v1 parity is unchanged.
468
+ if (m._capabilityLock || Object.hasOwn(locks, id)) continue;
469
+ console.log(` WARNING: ${id} at ${shortPath(m._dir)} is in installed/ but has no lock entry — reacquire it or move it to owned/`);
470
+ }
471
+ if (existsSync(LEGACY_HOME_CAPABILITIES_DIR)) console.log(` WARNING: legacy ~/.oats/capabilities exists and is no longer discovered — reinstall its packages at a config scope and remove it`);
472
+
473
+ // Distribution packages: package failures are distinguished from capability
474
+ // failures. Doctor is the DIAGNOSIS surface — human and JSON render the SAME
475
+ // doctorPackagesData computation (reviewer-455ba15 fix 4); fail-closed
476
+ // invalid-lock raises are diagnosed here, never consumed as data.
477
+ console.log("\nInstalled packages:");
478
+ const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
479
+ if (pkg.lockError) {
480
+ console.log(` ERROR: ${pkg.lockError.message} [${pkg.lockError.code}]`);
481
+ if (pkg.lockError.file) console.log(` fix or remove the offending entry in ${shortPath(pkg.lockError.file)} — the lock is never auto-repaired; package operations fail closed until it is valid`);
482
+ }
483
+ if (!pkg.lockError && !pkg.packages.length && !pkg.legacyLockFiles.length) console.log(" (none)");
484
+ for (const p of pkg.packages) {
485
+ console.log(` ${p.id}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]`);
486
+ for (const prob of p.problems) {
487
+ if (prob.code === "untrusted-surface") console.log(` ${prob.detail}`);
488
+ else console.log(` ERROR: ${prob.detail} [${prob.code}]`);
489
+ }
490
+ }
491
+ for (const l of pkg.legacyLockFiles) {
492
+ if (l.empty) console.log(` WARNING: ${shortPath(l.file)} is an empty lockfileVersion ${l.lockfileVersion} file — pending lock-format migration: run \`oats migrate --dir ${shortPath(l.level)}\` (converts to canonical v2)`);
493
+ else console.log(` WARNING: ${shortPath(l.file)} is lockfileVersion ${l.lockfileVersion} — \`oats migrate\` maps its capability locks to packages`);
494
+ }
495
+ if (pkg.officialMigration) {
496
+ const om = pkg.officialMigration;
497
+ console.log(`\nOfficial capability migration (0.18 bundled capabilities → official packages):`);
498
+ for (const c of om.capabilities) {
499
+ console.log(` ${c.capability} → package ${c.package}${c.via === "alias" ? " (catalog alias)" : ""} ${c.available ? "[mapped]" : "[no catalog mapping yet]"} [${shortPath(c.level)}]`);
500
+ }
501
+ if (om.status === "ready") console.log(` READY: migrate with \`${om.command}\` (plan it first with --dry-run; approvals are re-earned afterwards)`);
502
+ else console.log(` NOT YET AVAILABLE: ${om.reason}`);
503
+ }
504
+ for (const a of pkg.adoptedTemplates) {
505
+ if (a.status === "broken") {
506
+ console.log(`\nAdopted config template: BROKEN at ${shortPath(a.level)} — ${a.detail}`);
507
+ continue;
508
+ }
509
+ const drift = a.localChanges === null ? "" : a.localChanges ? " — local edits present (`oats config diff`)" : " — no local edits yet";
510
+ console.log(`\nAdopted config template: ${shortPath(a.file)} adopted ${a.package}:${a.template}${a.version ? `@${a.version}` : ""}${drift}`);
511
+ console.log(` recorded base ${shortPath(a.base)} (commit it — \`oats config sync\` compares against it; package updates never rewrite your config)`);
512
+ }
513
+ if (pkg.missingHostRequirements.length) {
514
+ console.log("\nMissing host commands (active capabilities):");
515
+ for (const req of pkg.missingHostRequirements) {
516
+ console.log(` ${req.command} — ${req.why || "required"} (requested by: ${req.requestedBy.map((r) => r.capability).join(", ")})`);
517
+ if (req.plan) console.log(` install with consent: ${req.consentCommand} (runs: ${req.plan.argv.join(" ")})`);
518
+ else if (req.docs) console.log(` install docs: ${req.docs}`);
519
+ }
520
+ }
521
+
522
+ if (soulName) {
523
+ const composition = doctorComposition(ctx, soulName);
524
+ console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
525
+ } else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
526
+ }
527
+
528
+ // ---------- config editing (structural: parse → mutate → re-serialize the capabilities block) ----------
529
+ function originToFrom(origin) {
530
+ const o = String(origin || "");
531
+ if (o.startsWith("installed:")) return "installed";
532
+ if (o.startsWith("owned:")) return "owned";
533
+ if (o.startsWith("path:")) return undefined; // path declarations stay hand-authored
534
+ return undefined;
535
+ }
536
+
537
+ function serializeBinding(value, indent) {
538
+ if (value === true || value === false) return ` ${value}`;
539
+ const lines = [""];
540
+ if (value.enabled !== undefined) lines.push(`${indent}enabled: ${value.enabled}`);
541
+ if (value.settings && Object.keys(value.settings).length) {
542
+ lines.push(`${indent}settings:`);
543
+ for (const [k, v] of Object.entries(value.settings)) lines.push(`${indent} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
544
+ }
545
+ return lines.join("\n");
546
+ }
547
+
548
+ /** Serialize one capability entry map at the given base indent, with the conventional injection comment. */
549
+ function serializeCapabilityEntry(id, entry, baseIndent) {
550
+ const i = baseIndent;
551
+ const lines = [];
552
+ if (entry.capability) lines.push(`${i}capability: ${entry.capability}`);
553
+ if (entry.from) lines.push(`${i}from: ${entry.from}`);
554
+ if (entry.global !== undefined) lines.push(`${i}global:${serializeBinding(entry.global, i + " ")}`);
555
+ const types = entry["agent-types"];
556
+ if (types && Object.keys(types).length) {
557
+ lines.push(`${i}agent-types:`);
558
+ for (const [t, v] of Object.entries(types)) lines.push(`${i} ${t}:${serializeBinding(v, i + " ")}`);
559
+ }
560
+ if (entry.souls && Object.keys(entry.souls).length) {
561
+ lines.push(`${i}souls:`);
562
+ for (const [s, v] of Object.entries(entry.souls)) lines.push(`${i} ${s}:${serializeBinding(v, i + " ")}`);
563
+ }
564
+ if (entry.settings && Object.keys(entry.settings).length) {
565
+ lines.push(`${i}settings:`);
566
+ for (const [k, v] of Object.entries(entry.settings)) lines.push(`${i} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
567
+ }
568
+ if (entry["injection-override"] !== undefined) lines.push(`${i}injection-override: ${entry["injection-override"]}`);
569
+ else if (entry.from === "owned" || String(entry.from || "").startsWith("path:"))
570
+ lines.push(`${i}# injection edited at source: .agents/capabilities/owned/${id}/injects/`);
571
+ else lines.push(`${i}# injection-override: .agents/injections/capabilities/${id}.md`);
572
+ return lines;
573
+ }
574
+
575
+ /** Re-serialize the whole `capabilities:` block from its parsed model. */
576
+ function serializeCapabilities(caps) {
577
+ const lines = ["capabilities:", " # Fundamental layers — exclusive slots; a capability entry or an explicit none.", " layers:"];
578
+ for (const layer of LAYERS) {
579
+ const entry = caps.layers?.[layer];
580
+ if (entry === undefined) continue;
581
+ if (entry === "none") { lines.push(` ${layer}: none`); continue; }
582
+ lines.push(` ${layer}:`);
583
+ lines.push(...serializeCapabilityEntry(entry.capability, entry, " "));
584
+ }
585
+ const additive = Object.entries(caps.additive || {});
586
+ if (additive.length) {
587
+ lines.push(" additive:");
588
+ for (const [id, entry] of additive) {
589
+ lines.push(` ${id}:`);
590
+ lines.push(...serializeCapabilityEntry(id, entry, " "));
591
+ }
592
+ }
593
+ return lines.join("\n") + "\n";
594
+ }
595
+
596
+ /** Replace (or append) the top-level capabilities: block in config text. */
597
+ function replaceCapabilitiesBlock(text, caps) {
598
+ const serialized = serializeCapabilities(caps);
599
+ const lines = text.replace(/\n*$/, "\n").split("\n");
600
+ const start = lines.findIndex((l) => /^capabilities:\s*(#.*)?$/.test(l));
601
+ if (start < 0) return text.replace(/\n*$/, "\n\n") + serialized;
602
+ let end = lines.length;
603
+ for (let i = start + 1; i < lines.length; i++) {
604
+ if (/^[^\s#]/.test(lines[i])) { end = i; break; }
605
+ if (/^#/.test(lines[i]) && i + 1 < lines.length && /^[^\s]/.test(lines[i + 1] || "")) { end = i; break; }
606
+ }
607
+ return [...lines.slice(0, start), ...serialized.replace(/\n$/, "").split("\n"), "", ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
608
+ }
609
+
610
+ /** Load the parsed capabilities model of a config file ({layers:{}, additive:{}}). */
611
+ function readCapabilitiesModel(file) {
612
+ if (!existsSync(file)) return { layers: {}, additive: {} };
613
+ const cfg = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8")));
614
+ const caps = cfg.capabilities || {};
615
+ return { layers: { ...(caps.layers || {}) }, additive: { ...(caps.additive || {}) } };
616
+ }
617
+
618
+ // ---------- use / activation ----------
619
+ function use() {
620
+ const requested = args[1];
621
+ if (!requested || requested.startsWith("--")) die("usage: oats use <capability|none> [--global|--type <agent-type>|--soul <name>] [--disable] [--layer <name>] [--settings k=v [k2=v2 ...]] [--dir <dir>]");
622
+ const dir = dirFlag();
623
+ const level = levelOf(dir);
624
+ const file = join(dir, "oats-config.yaml");
625
+ const layer = flag("layer");
626
+ if (layer && !LAYERS.includes(layer)) die(`--layer must be one of: ${LAYERS.join(", ")}`);
627
+ let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
628
+ const caps = readCapabilitiesModel(file);
629
+ if (requested === "none") {
630
+ if (!layer) die("oats use none requires --layer <name>");
631
+ caps.layers[layer] = "none";
632
+ writeFileSync(file, replaceCapabilitiesBlock(text, caps));
633
+ console.log(`Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
634
+ return;
635
+ }
636
+ const manifest = capabilityManifest(requested, dir);
637
+ if (!manifest) {
638
+ // A scope with NO oats-config.yaml anywhere in its chain is not a config
639
+ // level, so `capabilityManifests` — which walks the chain — never opens this
640
+ // scope's installed store: a capability acquired and locked right here would
641
+ // otherwise be reported as never acquired. Diagnose the missing chain
642
+ // instead. `oats use` still writes nothing: authoring an adopter's first
643
+ // config is `oats init`'s job, and guessing it here would be policy.
644
+ if (!configChain(dir).length && ownScopeCapabilityManifest(dir, requested)) {
645
+ // The remedy is `--raw` on purpose: it is offline, deterministic, and
646
+ // writes only the minimal config this scope is missing. It never names
647
+ // `--package <pkg>` — that arm read the provider out of the MERGED lock
648
+ // chain while this gate reads own-scope only, and it dead-ends whenever
649
+ // the provider exports no config template. Both scope mentions use the
650
+ // same rendering, so the printed command is copyable verbatim.
651
+ cmdFail("E_NO_CONFIG", `capability "${requested}" is present in the capability store at ${shellQuote(dir)}, but there is no oats-config.yaml at this scope or any level above it — \`oats use\` activates into a config file and this scope has none. Create the minimal one with \`oats init --raw --dir ${shellQuote(dir)}\`, then re-run \`oats use ${requested}\`.`);
652
+ return;
653
+ }
654
+ die(`unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
655
+ }
656
+ if (layer && manifest.layer !== layer) die(`capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
657
+ const targets = [["agent-types", flag("type")], ["souls", flag("soul")]].filter(([, value]) => value);
658
+ if (args.includes("--global")) targets.push(["global", undefined]);
659
+ if (targets.length > 1) die("choose exactly one of --global, --type, or --soul");
660
+ const [targetKind, targetName] = targets[0] || ["global", undefined];
661
+ const enabled = !args.includes("--disable");
662
+ // Locate or create the entry in the right subtree.
663
+ let entry;
664
+ if (manifest.layer) {
665
+ const existing = caps.layers[manifest.layer];
666
+ entry = existing && existing !== "none" && existing.capability === manifest.capability ? existing : { capability: manifest.capability };
667
+ if (existing && existing !== "none" && existing.capability !== manifest.capability && enabled) {
668
+ die(`fundamental layer ${manifest.layer} already binds ${existing.capability} at this level — disable it first`);
669
+ }
670
+ caps.layers[manifest.layer] = entry;
671
+ } else {
672
+ entry = caps.additive[manifest.capability] || {};
673
+ caps.additive[manifest.capability] = entry;
674
+ }
675
+ const from = originToFrom(manifest._origin);
676
+ if (from && !entry.from) entry.from = from;
677
+ const settingsArgs = [];
678
+ for (let i = 0; i < args.length; i++) {
679
+ if (args[i] !== "--settings") continue;
680
+ let consumed = 0;
681
+ for (let j = i + 1; j < args.length && !args[j].startsWith("--"); j++, consumed++) settingsArgs.push(args[j]);
682
+ if (!consumed) die("--settings expects one or more key=value pairs");
683
+ i += consumed;
684
+ }
685
+ if (settingsArgs.length) {
686
+ entry.settings = entry.settings && typeof entry.settings === "object" ? entry.settings : {};
687
+ for (const kv of settingsArgs) {
688
+ const eq = kv.indexOf("=");
689
+ if (eq <= 0) die(`--settings expects key=value, got "${kv}"`);
690
+ // WRITE side of the refusals the readers enforce. Two distinct hazards on
691
+ // this one line:
692
+ // - `--settings __proto__=x` assigned through the inherited setter,
693
+ // which swallowed the entry, and the command reported success for a
694
+ // setting it never wrote;
695
+ // - the VALUE is rendered verbatim into one `key: value` line, so a
696
+ // newline-bearing value stopped being a value and became document —
697
+ // a crafted one added a whole second capability entry.
698
+ // Both fail closed, before anything is written.
699
+ const key = assertSafeConfigWriteKey(kv.slice(0, eq), `--settings key ${JSON.stringify(kv.slice(0, eq))}`);
700
+ entry.settings[key] = assertSafeConfigValue(kv.slice(eq + 1), `--settings value for ${JSON.stringify(key)}`);
701
+ }
702
+ }
703
+ if (targetKind === "global") entry.global = enabled;
704
+ else {
705
+ // A layer entry with no explicit targets is implicitly global — materialize that
706
+ // before narrowing, so adding a soul/type binding doesn't silently drop everyone else.
707
+ if (manifest.layer && entry.global === undefined && !entry["agent-types"] && !entry.souls) entry.global = true;
708
+ entry[targetKind] = entry[targetKind] && typeof entry[targetKind] === "object" ? entry[targetKind] : {};
709
+ // Same write-side refusal, and for the same two reasons: `--soul
710
+ // __proto__` was swallowed by the inherited setter and reported as
711
+ // activated, and a `--soul`/`--type` NAME is written as a mapping key, so a
712
+ // newline in it injects document exactly like a settings value does.
713
+ entry[targetKind][assertSafeConfigWriteKey(targetName, `--${targetKind === "agent-types" ? "type" : "soul"} name ${JSON.stringify(String(targetName))}`)] = enabled;
714
+ }
715
+ writeFileSync(file, replaceCapabilitiesBlock(text, caps));
716
+ console.log(`${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
717
+ for (const miss of capabilityMissingRequires(manifest.capability, dir)) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
718
+ console.log("New instances receive the resolved capability; committed souls are unchanged.");
719
+ }
720
+
721
+ // ---------- install / trust / list / remove / migrate ----------
722
+ const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
723
+ /** `oats install <source>`: distribution-package acquisition (exact-lock closure,
724
+ * activates nothing). Marketplace capability ids keep the legacy capability path
725
+ * until workstream 3 publishes the official packages. */
726
+ function install() {
727
+ const src = args[1];
728
+ const dir = dirFlag();
729
+ if (!src || src.startsWith("--")) {
730
+ // Usage errors surface BEFORE any restore/network side effect: a malformed
731
+ // --accept-requirement must not mutate the deployment and then report E_USAGE.
732
+ flagAll("accept-requirement");
733
+ reconcile(dir);
734
+ return;
735
+ }
736
+ const retiredReason = retiredCapabilityReason(src);
737
+ if (retiredReason) cmdFail("retired-capability", retiredReason);
738
+ // Package source? (git/path with an oats-package.json, or a catalog id) — otherwise legacy capability acquisition.
739
+ let parsedSrc;
740
+ try { parsedSrc = parsePackageSource(src); } catch { parsedSrc = undefined; }
741
+ const catalogId = parsedSrc?.kind === "catalog" ? parsedSrc.id : undefined;
742
+ const hasOfficialPackage = !!catalogId && Object.hasOwn(officialPackageCatalog(), catalogId);
743
+ // Once an official package catalog entry exists it becomes the default
744
+ // acquisition route for that short id. Existing v1 installs keep working,
745
+ // but a deliberate `oats install oats.okf` now acquires the package rather than
746
+ // creating another legacy capability lock.
747
+ const isMarketplaceCap = parsedSrc?.kind === "catalog" && !!marketplaceCapabilities()[catalogId] && !hasOfficialPackage;
748
+ const isLocalPackage = parsedSrc?.kind === "path" && existsSync(join(parsedSrc.path, "oats-package.json"));
749
+ const isCatalogPackage = parsedSrc?.kind === "catalog" && !isMarketplaceCap;
750
+ let gitInspection;
751
+ if (parsedSrc && (parsedSrc.kind === "git" || isLocalPackage || isCatalogPackage)) {
752
+ // Remote Git may be either a distribution package or the documented
753
+ // legacy standalone-capability repository. Inspect the fetched ROOT before
754
+ // any scope lock preflight; never infer root layout from closure errors.
755
+ if (parsedSrc.kind === "git") {
756
+ try { gitInspection = inspectGitSourceRoot(src); }
757
+ catch (e) { cmdFail(e.code || "invalid-source", e.message || e); return; }
758
+ if (gitInspection.payloadPackage) {
759
+ try { installPackage(dir, src, { rootSnapshot: gitInspection }); }
760
+ finally { gitInspection.cleanup(); }
761
+ return;
762
+ }
763
+ // Legacy standalone-capability repositories predate contained package
764
+ // roots, so the fallback only applies to a REPOSITORY-ROOT capability
765
+ // that was not asked for a specific path. A repo whose root carries
766
+ // oats-package.json must never silently downgrade to capability
767
+ // acquisition just because the selected path holds no package.
768
+ if (gitInspection.explicitPath || gitInspection.package || !gitInspection.capability) {
769
+ const where = `package path "${gitInspection.path}"`;
770
+ const reason = gitInspection.package
771
+ ? `Git source ${src} has an oats-package.json at the repository ROOT but no package at ${where}${gitInspection.explicitPath ? "" : " (the default)"} — select the root explicitly with \`${src}#.\``
772
+ : gitInspection.explicitPath
773
+ ? `Git source ${src} has no oats-package.json at ${where}`
774
+ : `Git source ${src} has no oats-package.json at ${where} (the default package path) and no oats.json at its root`;
775
+ gitInspection.cleanup();
776
+ cmdFail("invalid-package-manifest", reason); return;
777
+ }
778
+ // Standalone capability: hand the SAME fetched snapshot to legacy
779
+ // acquisition (which re-verifies that exact root layout before copying).
780
+ } else { installPackage(dir, src); return; }
781
+ }
782
+ let known;
783
+ try { known = gitInspection ? undefined : capabilityManifest(src, dir); }
784
+ catch (e) { gitInspection?.cleanup(); cmdFail(e.code || "invalid-lock", e.message || e); return; }
785
+ if (known) {
786
+ if (JSON_MODE) { jsonOk({ alreadyAcquired: known.capability, version: known.version || null }); return; }
787
+ console.log(`Already acquired capability ${known.capability} (${known.version || "unversioned"}); not activated or updated.`);
788
+ return;
789
+ }
790
+ let r;
791
+ try { r = acquireCapability(dir, src, { rootSnapshot: gitInspection }); }
792
+ catch (e) { cmdFail(e.code || "invalid-source", e.message); return; }
793
+ finally { gitInspection?.cleanup(); }
794
+ const lock = {
795
+ source: r.source,
796
+ version: r.manifest.version || null,
797
+ ...(r.commit ? { commit: r.commit } : {}), integrity: r.integrity,
798
+ // Marketplace packages ship with the kernel you already installed — they are
799
+ // trusted at acquisition; third-party git/path installs need explicit `oats trust`.
800
+ trustedExecutables: !!r.marketplace,
801
+ };
802
+ if (r.marketplace && r.manifest.environment?.length) {
803
+ (JSON_MODE ? console.error : console.log)(`Requested launch environment: ${r.manifest.environment.join(", ")}`);
804
+ }
805
+ let lockFile;
806
+ try { lockFile = writeCapabilityLock(dir, r.manifest.capability, lock); }
807
+ catch (e) {
808
+ // Refused lock write (e.g. legacy-lock: a converted scope rejects a NEW v1
809
+ // capability entry) must
810
+ // not strand the acquired artifact — compensate before failing.
811
+ rmSync(r.dest, { recursive: true, force: true });
812
+ cmdFail(e.code || "legacy-lock", e.message); return;
813
+ }
814
+ if (JSON_MODE) { jsonOk({ capability: r.manifest.capability, version: r.manifest.version || null, integrity: r.integrity, source: r.source, dir: r.dest, lockFile, marketplace: !!r.marketplace, trustedExecutables: !!r.marketplace }); return; }
815
+ console.log(`Acquired ${r.manifest.capability} → ${shortPath(r.dest)}`);
816
+ console.log(`Locked ${r.manifest.version || r.commit || "exact artifact"} (${r.integrity}) in ${shortPath(lockFile)}; not activated.`);
817
+ if (r.marketplace) console.log("Marketplace package: executables trusted at acquisition.");
818
+ else if (r.manifest.commands || r.manifest.hooks || r.manifest.environment?.length) {
819
+ if (r.manifest.environment?.length) console.log(`Future trust request includes launch environment: ${r.manifest.environment.join(", ")}`);
820
+ console.log(`Executable surface is blocked until: oats trust ${r.manifest.capability} --dir ${shortPath(dir)}`);
821
+ }
822
+ }
823
+
824
+ /** Lock-file levels from dir upward (closest last — outermost first), like restoreCapabilities' walk. */
825
+ function lockLevelsUp(dir) {
826
+ const levels = [];
827
+ for (let d = resolve(dir); ; d = dirname(d)) {
828
+ if (existsSync(join(d, OATS_LOCK_FILE))) levels.push(d);
829
+ if (dirname(d) === d) break;
830
+ }
831
+ return levels.reverse();
832
+ }
833
+
834
+ /** Check/restore one level's v2 package locks via the ENGINE's restorePackages
835
+ * (exact restore, no ref advancement, staging + integrity/capability/deps
836
+ * verification inside). The engine walks the lock chain from the given dir;
837
+ * reconciliation calls it per deduplicated level and keeps that level's rows. */
838
+ /** Map engine restore rows to WS2 report items (kind package). */
839
+ const pkgRow = (r) => ({
840
+ id: r.package, level: r.level, package: true, dir: r.dir,
841
+ status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
842
+ });
843
+
844
+ /** Restore-and-partition for reconciliation (reviewer-455ba15 fix 1): the
845
+ * engine's restorePackages walks the WHOLE lock chain from a directory and has
846
+ * no exact-level option, so invoke it ONCE per deepest scope and PARTITION the
847
+ * report rows by lock level — never re-invoke per level (each re-invocation
848
+ * re-runs restore side effects for every ancestor lock). Returns a Map
849
+ * level(resolved) → rows. */
850
+ function partitionedPackageRestore(deepestDir) {
851
+ const byLevel = new Map();
852
+ const add = (level, row) => {
853
+ const key = resolve(level);
854
+ if (!byLevel.has(key)) byLevel.set(key, []);
855
+ byLevel.get(key).push(row);
856
+ };
857
+ for (const r of restorePackages(deepestDir)) add(r.level, pkgRow(r));
858
+ // EMPTY v1 lock files surface too (maintainer ruling): the engine's restore
859
+ // report only rows NON-empty v1 files. Walk the raw lock chain (a lock-only
860
+ // scope has no config, so configChain-based reads cannot see it) and emit a
861
+ // LEGACY row for each empty v1 file so reconciliation shows the pending
862
+ // lock-format migration.
863
+ for (const level of lockLevelsUp(deepestDir)) {
864
+ try {
865
+ const parsed = JSON.parse(readFileSync(join(level, OATS_LOCK_FILE), "utf8"));
866
+ if (parsed.lockfileVersion !== 2 && !Object.keys(parsed.capabilities || {}).length) {
867
+ add(level, { id: null, level, package: true, status: "legacy", reason: `empty lockfileVersion ${parsed.lockfileVersion ?? 1} file — pending lock-format migration: oats migrate --dir ${level}` });
868
+ }
869
+ } catch { /* malformed locks raise via restorePackages above */ }
870
+ }
871
+ return byLevel;
872
+ }
873
+
874
+ function installPackage(dir, src, opts = {}) {
875
+ const bail = (e) => (JSON_MODE ? jsonFail(e.code || "invalid-source", e.message || e) : die(e.message || e));
876
+ let r;
877
+ try { r = acquirePackage(dir, src, opts); }
878
+ catch (e) { bail(e); return true; }
879
+ // Packages are transport; capabilities are what lands on disk. Report both,
880
+ // and let the CAPABILITY rows carry the provenance an operator acts on.
881
+ if (JSON_MODE) { jsonOk({ root: r.root, installed: r.installed, capabilities: r.capabilities, lockFile: r.lockFile, depWarnings: r.depWarnings || [] }); return true; }
882
+ for (const p of r.installed) {
883
+ console.log(`${p.kept ? "ok " : "Acquired "}${p.package}@${p.version}`);
884
+ console.log(` locked ${p.commit === "local" ? "local tree" : p.commit} at path ${p.path} (${p.integrity})`);
885
+ for (const c of r.capabilities.filter((x) => x.package === p.package)) {
886
+ console.log(` capability ${c.capability}@${c.version}${c.layer ? ` layer: ${c.layer}` : ""} → ${shortPath(c.dir)} (${c.integrity})`);
887
+ }
888
+ if (!r.capabilities.some((x) => x.package === p.package)) console.log(" capabilities: (none)");
889
+ }
890
+ for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
891
+ console.log(`Locked in ${shortPath(r.lockFile)}; nothing activated.`);
892
+ // Read the executable surface off the ENGINE's projection, not a config-chain
893
+ // manifest lookup: at a scope with no config yet, that lookup sees nothing.
894
+ const executables = r.capabilities
895
+ .filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
896
+ .map((c) => c.capability);
897
+ if (executables.length) console.log(`Executable surfaces blocked until trusted: ${executables.map((c) => `oats trust ${c}`).join("; ")}`);
898
+ return true;
899
+ }
900
+
901
+ /** Bare `oats install` chain restore: engine packages (lock v2) + legacy locked
902
+ * capabilities (v1). Returns { report, failed }; output goes to stdout (human)
903
+ * or stderr (JSON mode) — the reconcile envelope owns stdout in JSON mode. */
904
+ function restore(dir) {
905
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
906
+ // Fail-closed locks: restorePackages/restoreCapabilities RAISE typed
907
+ // invalid-lock — let the reconcile boundary surface the code verbatim
908
+ // (never softened to empty); this throw is caught by reconcile().
909
+ const pkgReport = restorePackages(dir).map((r) => ({
910
+ id: r.package, level: r.level, package: true, dir: r.dir,
911
+ status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
912
+ }));
913
+ const report = [...restoreCapabilities(dir), ...pkgReport];
914
+ if (!report.length) note("Nothing to restore — no locked capabilities in the config chain.");
915
+ let failed = 0;
916
+ for (const r of report) {
917
+ const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
918
+ if (r.status === "present") note(`ok ${what} (${shortPath(r.dir)})`);
919
+ else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)}${r.integrity ? ` (${r.integrity})` : ""}`);
920
+ else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
921
+ else if (r.status === "retired") { failed++; note(`RETIRED ${what} ${r.reason}`); }
922
+ else { failed++; note(`FAILED ${what} ${r.reason}`); }
923
+ }
924
+ return { report, failed };
925
+ }
926
+
927
+ /** Unsuccessful restore statuses and their frozen taxonomy codes (reviewer-6f0a3bd:
928
+ * "unrestorable" and "retired" must not report ok). */
929
+ const UNSUCCESSFUL_RESTORE = { failed: undefined, unrestorable: "invalid-source", retired: "retired-capability" };
930
+
931
+ /** One artifact report item → the machine shape (kind capability|package). */
932
+ const artifactJson = (r) => ({
933
+ id: r.id, kind: r.package ? "package" : "capability", level: r.level,
934
+ status: r.status, ...(r.dir ? { dir: r.dir } : {}), ...(r.reason ? { reason: r.reason } : {}),
935
+ ...(Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status) ? { code: r.code || UNSUCCESSFUL_RESTORE[r.status] || "integrity-drift" } : {}),
936
+ });
937
+
938
+ /** Emit the reconcile/restore result: human exit or the single-envelope JSON contract.
939
+ * Full success → { ok: true, result }. ANY artifact or consented-install failure →
940
+ * nonzero with error.code E_RECONCILE_FAILED and the SAME complete report under
941
+ * error.details — partial outcomes are never lost. */
942
+ function emitReconcileResult({ boundary, boundaryKind, scopes, requirements, failures }) {
943
+ const result = { boundary, boundaryKind, scopes, requirements, failures };
944
+ if (JSON_MODE) {
945
+ if (failures.length) {
946
+ console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_RECONCILE_FAILED", message: `${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`, details: result } }));
947
+ process.exit(1);
948
+ }
949
+ jsonOk(result);
950
+ return;
951
+ }
952
+ if (failures.length) {
953
+ console.log("\nFailures by scope:");
954
+ for (const f of failures) console.log(` ${shortPath(f.scope)}: ${f.id} — ${f.reason}`);
955
+ die(`${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`);
956
+ }
957
+ }
958
+
959
+ /** Bare `oats install` at a team boundary: reconcile the whole workspace — restore the
960
+ * boundary scope's graph (its ancestor chain), then every descendant scope's own
961
+ * lock graph EXACTLY ONCE, in deterministic path order, with pruned discovery;
962
+ * verify v2 package locks against the installed package store; validate
963
+ * config-referenced capabilities against visible locked packages; aggregate
964
+ * missing requirements and failures by scope.
965
+ * Non-team scopes keep current-chain behavior unless --recursive names a boundary. */
966
+ /** Bare `oats install` (no source): current-chain restore or team-boundary
967
+ * reconciliation. JSON-mode boundary: ANY throw before emitReconcileResult
968
+ * (malformed lock/config, discovery failures) must still yield the single
969
+ * envelope — never empty stdout with a stack trace. */
970
+ function reconcile(dir) {
971
+ try { reconcileInner(dir); }
972
+ catch (e) {
973
+ if (JSON_MODE) {
974
+ console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: e.code || "E_RECONCILE_FAILED", message: String(e.message || e) } }));
975
+ process.exit(1);
976
+ }
977
+ die(e.message || e);
978
+ }
979
+ }
980
+
981
+ function reconcileInner(dir) {
982
+ const cfgFile = join(dir, "oats-config.yaml");
983
+ const declaresTeamHere = existsSync(cfgFile) && !!withConfigFile(cfgFile, () => parseYamlNested(readFileSync(cfgFile, "utf8"))).team;
984
+ const recursive = args.includes("--recursive");
985
+ if (!declaresTeamHere && !recursive) {
986
+ // Current-chain behavior, plus the requirements gate for this chain's active capabilities.
987
+ const { report, failed } = restore(dir);
988
+ const requirements = requirementsGate([dir]);
989
+ const failures = [
990
+ // "legacy" is informational (v1 locks restore via the capability path);
991
+ // every other unsuccessful status is a failure (incl. retired/unrestorable
992
+ // per reviewer-6f0a3bd — they must not report ok).
993
+ ...report.filter((r) => Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status)).map((r) => ({ scope: r.level, id: r.package ? `package ${r.id}` : r.id, reason: r.reason, code: r.code || UNSUCCESSFUL_RESTORE[r.status] })),
994
+ ...requirements.filter((q) => q.outcome === "failed").map((q) => ({ scope: dir, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" })),
995
+ ];
996
+ void failed;
997
+ emitReconcileResult({
998
+ boundary: dir, boundaryKind: "chain",
999
+ scopes: [{ scope: dir, artifacts: report.map(artifactJson) }],
1000
+ requirements, failures,
1001
+ });
1002
+ return;
1003
+ }
1004
+ const boundary = dir;
1005
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1006
+ // The chosen boundary is printed BEFORE any network or host work — always.
1007
+ note(`Workspace reconciliation boundary: ${shortPath(boundary)}${declaresTeamHere ? " (team scope)" : " (--recursive)"}`);
1008
+ const scopes = [boundary, ...discoverWorkspaceScopes(boundary)];
1009
+ const failures = [];
1010
+ const scopeReports = [];
1011
+ let reportedAny = false;
1012
+ const restoredLevels = new Set(); // each lock level's graph restores exactly once
1013
+ const packageCheckedLevels = new Set(); // each level's package-lock rows consumed exactly once
1014
+ // reviewer-455ba15 fix 1 — partition-not-rerun: run the engine's chain-walking
1015
+ // package restore as FEW times as the API allows and hand out each level's
1016
+ // rows exactly once. One invocation covers a scope's entire ancestor chain;
1017
+ // rows are stashed so no level is ever REPORTED twice and no already-walked
1018
+ // level triggers a re-invocation. RESIDUAL (pending WS1's exact-levels API,
1019
+ // relayed as a want): a descendant owning its own lock necessarily re-walks
1020
+ // its ancestors inside the engine — present/valid ancestor artifacts re-verify
1021
+ // with local reads only, but a FAILED ancestor fetch may retry once per
1022
+ // lock-owning descendant. The exact-once reporting contract holds.
1023
+ const pendingPkgRows = new Map(); // level(resolved) → rows not yet consumed
1024
+ const packageRowsFor = (scope, levels) => {
1025
+ const wanted = levels.map((l) => resolve(l)).filter((l) => !packageCheckedLevels.has(l));
1026
+ if (!wanted.length) return [];
1027
+ if (wanted.some((l) => !pendingPkgRows.has(l))) {
1028
+ // One restore invocation covers scope's whole chain; stash every level's
1029
+ // rows so later scopes never re-invoke for already-walked levels.
1030
+ for (const [lvl, rows] of partitionedPackageRestore(scope)) {
1031
+ if (!pendingPkgRows.has(lvl)) pendingPkgRows.set(lvl, rows);
1032
+ }
1033
+ }
1034
+ const out = [];
1035
+ for (const l of wanted) {
1036
+ packageCheckedLevels.add(l);
1037
+ out.push(...(pendingPkgRows.get(l) || []));
1038
+ }
1039
+ return out;
1040
+ };
1041
+ for (const scope of scopes) {
1042
+ // Boundary: full ancestor chain (current-chain semantics). Descendants: their
1043
+ // own level only — every level between boundary and descendant is either the
1044
+ // boundary chain or an earlier discovered scope, so no level repeats and no
1045
+ // failed ancestor restore is retried (or hidden) per descendant.
1046
+ const chainLevels = scope === boundary ? undefined : [scope];
1047
+ const report = restoreCapabilities(scope, chainLevels ? { levels: chainLevels.filter((l) => !restoredLevels.has(resolve(l))) } : undefined)
1048
+ .filter((r) => !restoredLevels.has(resolve(r.level)));
1049
+ // v2 package locks: every lock level this scope covers (the boundary covers
1050
+ // its whole ancestor chain), each restored/verified exactly once.
1051
+ report.push(...packageRowsFor(scope, scope === boundary ? lockLevelsUp(boundary) : [scope]));
1052
+ for (const r of report) {
1053
+ reportedAny = true;
1054
+ const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
1055
+ if (r.status === "present") note(`ok ${what} [${shortPath(r.level)}]`);
1056
+ else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)} [${shortPath(r.level)}]`);
1057
+ else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
1058
+ else if (r.status === "retired") { failures.push({ scope: r.level, id: what, reason: r.reason, code: "retired-capability" }); note(`RETIRED ${what} ${r.reason} [${shortPath(r.level)}]`); }
1059
+ else { failures.push({ scope: r.level, id: what, reason: r.reason, code: r.code }); note(`FAILED ${what} ${r.reason} [${shortPath(r.level)}]`); }
1060
+ }
1061
+ if (scope === boundary) for (const cfg of configChain(boundary)) restoredLevels.add(resolve(cfg._level));
1062
+ for (const r of report) restoredLevels.add(resolve(r.level));
1063
+ restoredLevels.add(resolve(scope));
1064
+ // Validate: every config-referenced installed capability supplied by a visible locked package/capability lock.
1065
+ if (existsSync(join(scope, "oats-config.yaml"))) {
1066
+ try {
1067
+ const supplied = lockedPackageCapabilities(scope);
1068
+ const capLocks = readCapabilityLocks(scope);
1069
+ for (const cfg of configChain(scope)) {
1070
+ if (resolve(cfg._level) !== resolve(scope)) continue;
1071
+ for (const [slot, entry] of Object.entries(cfg.capabilities?.layers || {})) {
1072
+ if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(entry.capability) && !capLocks[entry.capability]) {
1073
+ failures.push({ scope, id: entry.capability, reason: `referenced by capabilities.layers.${slot} but supplied by no visible locked package` });
1074
+ }
1075
+ }
1076
+ for (const [id, entry] of Object.entries(cfg.capabilities?.additive || {})) {
1077
+ if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(id) && !capLocks[id]) {
1078
+ failures.push({ scope, id, reason: "referenced in config but supplied by no visible locked package" });
1079
+ }
1080
+ }
1081
+ }
1082
+ } catch (e) { failures.push({ scope, id: "(config)", reason: e.message }); }
1083
+ }
1084
+ scopeReports.push({ scope, artifacts: report.map(artifactJson) });
1085
+ }
1086
+ if (!reportedAny && scopes.length === 1) note("Nothing to restore — no locked capabilities or packages found in the boundary.");
1087
+ const requirements = requirementsGate(scopes);
1088
+ for (const q of requirements) {
1089
+ if (q.outcome === "failed") failures.push({ scope: boundary, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" });
1090
+ }
1091
+ emitReconcileResult({
1092
+ boundary, boundaryKind: declaresTeamHere ? "team" : "recursive",
1093
+ scopes: scopeReports, requirements, failures,
1094
+ });
1095
+ }
1096
+
1097
+ /** Host-requirement consent gate. Requirements are considered only for capabilities
1098
+ * activated somewhere in the reconciled scopes, deduplicated by command. Interactive
1099
+ * runs prompt per requirement with the exact command/source/version and state scope;
1100
+ * non-interactive runs NEVER install by default — automation names each accepted
1101
+ * requirement via --accept-requirement <command>; --no-requirements skips entirely.
1102
+ * Skipping leaves an actionable doctor warning (doctor recomputes missing commands).
1103
+ * Returns structured entries with a stable outcome enum:
1104
+ * "installed" consented install ran and the command verified on PATH
1105
+ * "failed" consented install errored or PATH verification missed (→ reconcile failure)
1106
+ * "consent-required" not explicitly accepted — nothing installed
1107
+ * "skipped" --no-requirements, or no safe installer for this host
1108
+ * JSON plan data equals the human prompt plan (argv/source/version/scope/requestedBy;
1109
+ * never shell text). In JSON mode all prose goes to stderr. */
1110
+ function requirementsGate(scopes) {
1111
+ // Malformed repeatable flags are usage errors regardless of which branch
1112
+ // runs — validate up front so --no-requirements cannot mask them.
1113
+ const accepted = new Set(flagAll("accept-requirement"));
1114
+ // Explicitly named requirements bypass runtime scoping, so the remediation
1115
+ // command a failed spawn prints actually installs something.
1116
+ const missing = aggregateMissingRequirements(scopes, { accepted });
1117
+ if (!missing.length) return [];
1118
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1119
+ const entryOf = (req, outcome, extra = {}) => ({
1120
+ command: req.command, kind: req.kind || "host-command",
1121
+ runtime: req.runtime || null, package: req.package || null, why: req.why || null,
1122
+ // `steps` is the ORDERED sequence runRequirementInstall actually executes;
1123
+ // `argv` is only its last command. Serializing argv alone hid a
1124
+ // `claude plugin marketplace add <source>` — a lower-trust source
1125
+ // registration — from every client consenting through the JSON API
1126
+ // (reviewer-final0130bc8). Always present, exactly as doctor renders it, so
1127
+ // single- and multi-step plans have one shape.
1128
+ plan: req.plan && !req.plan.unavailable
1129
+ ? { manager: req.plan.manager, argv: req.plan.argv, steps: req.plan.steps || [req.plan.argv], source: req.plan.source, version: req.plan.version || null, scope: req.plan.scope }
1130
+ : null,
1131
+ requestedBy: req.requestedBy, docs: req.docs || null, outcome, ...extra,
1132
+ });
1133
+ // Fail-closed identity/conflict policy (E_REQUIREMENT_POLICY): invalid command
1134
+ // tokens and same-command conflicting plans are NEVER consentable or installable
1135
+ // — they fail reconciliation deterministically with provenance, even under
1136
+ // --no-requirements (skipping consent does not skip safety validation).
1137
+ const policyEntries = [];
1138
+ for (const req of missing) {
1139
+ if (req.invalid) {
1140
+ note(` INVALID requirement command ${JSON.stringify(req.command)} — ${req.invalid} (requested by: ${req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ")})`);
1141
+ policyEntries.push(entryOf(req, "failed", { reason: req.invalid, code: "E_REQUIREMENT_POLICY" }));
1142
+ } else if (req.conflict) {
1143
+ note(` CONFLICT for command "${req.command}": capabilities request non-identical install plans — no install is offered`);
1144
+ // Show the FULL sequence: two capabilities can agree on the final install
1145
+ // command while registering different third-party sources before it.
1146
+ for (const p of req.conflict.plans) {
1147
+ const shown = p.steps?.length ? p.steps.map((a) => a.join(" ")).join(" && ") : (p.argv ? p.argv.join(" ") : p.unavailable || "no plan");
1148
+ note(` ${p.capability} [${shortPath(p.scope)}]: ${shown}`);
1149
+ }
1150
+ policyEntries.push(entryOf(req, "failed", { reason: "conflicting install plans for the same command", code: "E_REQUIREMENT_POLICY", conflict: req.conflict }));
1151
+ }
1152
+ }
1153
+ const consentable = missing.filter((req) => !req.invalid && !req.conflict);
1154
+ if (args.includes("--no-requirements")) return [...policyEntries, ...consentable.map((req) => entryOf(req, "skipped", { reason: "--no-requirements" }))];
1155
+ const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
1156
+ const out = [...policyEntries];
1157
+ if (consentable.length) note(`\nMissing requirements for active capabilities (${consentable.length}):`);
1158
+ for (const req of consentable) {
1159
+ const requesters = req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ");
1160
+ note(` ${req.command} — ${req.why || "required"} (requested by: ${requesters})`);
1161
+ const plan = req.plan;
1162
+ if (!plan || plan.unavailable) {
1163
+ note(` no safe installer: ${plan?.unavailable || "no recipe"}${req.docs ? ` — install docs: ${req.docs}` : ""}`);
1164
+ out.push(entryOf(req, "skipped", { reason: plan?.unavailable || "no safe installer" }));
1165
+ continue;
1166
+ }
1167
+ // Show EVERY step: installing a Claude plugin also registers a third-party
1168
+ // marketplace, and consent to that must be visible, not implied.
1169
+ const shown = (plan.steps?.length ? plan.steps : [plan.argv]).map((a) => a.join(" ")).join(" && ");
1170
+ note(` installer: ${shown} (source: ${plan.source}${plan.version ? `, version ${plan.version}` : ""}; ${plan.scope})`);
1171
+ let consent = accepted.has(req.command);
1172
+ if (!consent && interactive) {
1173
+ process.stdout.write(` Run this install now? [y/N] `);
1174
+ const buf = Buffer.alloc(64);
1175
+ let answer = "";
1176
+ try { answer = buf.toString("utf8", 0, readSync(process.stdin.fd, buf, 0, 64)).trim().toLowerCase(); } catch { /* EOF */ }
1177
+ consent = answer === "y" || answer === "yes";
1178
+ }
1179
+ if (!consent) {
1180
+ note(` skipped — ${interactive ? "not consented" : "non-interactive; pass --accept-requirement " + req.command + " to install"}; \`oats doctor\` will keep warning until ${req.command} is ${req.kind === "runtime-package" ? `installed for ${req.runtime}` : "on PATH"}`);
1181
+ out.push(entryOf(req, "consent-required"));
1182
+ continue;
1183
+ }
1184
+ try {
1185
+ const r = runRequirementInstall(plan, JSON_MODE ? { stdio: ["ignore", 2, 2] } : {});
1186
+ // A runtime package is verified in its runtime's package list, never on
1187
+ // PATH — saying "on PATH" for one would be false either way it lands.
1188
+ const where = req.kind === "runtime-package" ? `installed for ${req.runtime}` : "on PATH";
1189
+ if (r.onPath) { note(` installed — ${req.command} verified ${where}`); out.push(entryOf(req, "installed", { onPath: true })); }
1190
+ else { note(` FAILED: install ran but ${req.command} is still not ${where}${req.kind === "runtime-package" ? "" : " — check your shell PATH/prefix"}`); out.push(entryOf(req, "failed", { onPath: false, reason: `install ran but the requirement is still not ${where}` })); }
1191
+ } catch (e) {
1192
+ note(` FAILED: ${e.message}`);
1193
+ out.push(entryOf(req, "failed", { onPath: false, reason: e.message }));
1194
+ }
1195
+ }
1196
+ note("Requirement consent is separate from capability trust — installing a binary does not activate or approve any capability.");
1197
+ return out;
1198
+ }
1199
+
1200
+ /** oats trust <capability> | oats trust <package> --all-capabilities */
1201
+ function trust() {
1202
+ const id = args[1];
1203
+ if (!id || id.startsWith("--")) { cmdFail("E_USAGE", "usage: oats trust <capability> [--dir <dir>] | oats trust <package> --all-capabilities [--dir <dir>]"); return; }
1204
+ const dir = dirFlag();
1205
+ const all = args.includes("--all-capabilities");
1206
+ // Package-backed approval path (per-capability, or explicit bulk on a package id).
1207
+ let pkgs, locks;
1208
+ try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
1209
+ // findLast: the listing runs outermost → innermost, and an identity resolves
1210
+ // to the CLOSEST scope that locks it — the same rule the merged lock maps use.
1211
+ const backing = all ? pkgs.findLast((p) => p.package === id) : pkgs.findLast((p) => p.capabilities.some((c) => c.id === id));
1212
+ if (backing) {
1213
+ // Approval is per capability unless --all-capabilities is explicit. Print
1214
+ // exactly the authority this invocation will persist, before persisting it;
1215
+ // JSON mode uses stderr so stdout remains one machine envelope.
1216
+ const requested = all ? backing.capabilities : backing.capabilities.filter((c) => c.id === id);
1217
+ const out = JSON_MODE ? console.error : console.log;
1218
+ out(`Package ${backing.package}@${backing.version} ${all ? "full" : "requested"} executable surface:`);
1219
+ for (const c of requested) {
1220
+ const cmds = Object.keys(c.manifest.commands || {});
1221
+ const hooks = Object.keys(c.manifest.hooks || {});
1222
+ const environment = c.manifest.environment || [];
1223
+ out(` ${c.id}: commands [${cmds.join(", ") || "none"}], hooks [${hooks.join(", ") || "none"}], launch environment [${environment.join(", ") || "none"}]`);
1224
+ }
1225
+ // FAIL CLOSED BEFORE APPROVING. The engine binds approval to the artifact's
1226
+ // integrity, but integrity alone cannot see a `.oats-installation.json` that
1227
+ // claims a different origin than the lock — and approving a capability whose
1228
+ // own provenance is disputed is exactly the thing trust must not do.
1229
+ const trustRows = levelRows(locks, backing.level);
1230
+ const disputed = backing.capabilities
1231
+ .filter((c) => all || c.id === id)
1232
+ .map((c) => capabilityHealth(backing.level, c, trustRows.capabilities[c.id], trustRows.packages[backing.package]))
1233
+ .filter((h) => h.status !== "ok" && h.status !== "untrusted");
1234
+ if (disputed.length) { cmdFail(disputed[0].code || "invalid-lock", `refusing to trust: ${disputed.map((h) => h.detail).join("; ")}`); return; }
1235
+ let r;
1236
+ try { r = approveCapability(dir, id, { allCapabilities: all }); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
1237
+ // Approval binds to each capability's exact MATERIALIZED ARTIFACT, so the
1238
+ // integrity reported is per capability — there is no package-level digest
1239
+ // to approve against and none to print.
1240
+ const approvedIntegrity = {};
1241
+ for (const c of backing.capabilities) if (r.approved.includes(c.id)) approvedIntegrity[c.id] = c.integrity || null;
1242
+ if (JSON_MODE) {
1243
+ // The engine's own surface, not a re-derivation: what it approved and what
1244
+ // it saw must be the same object.
1245
+ jsonOk({ package: r.package, level: r.level, approved: r.approved, skipped: r.skipped, approvedIntegrity, executableSurface: r.executableSurface, file: r.file });
1246
+ return;
1247
+ }
1248
+ for (const c of r.approved) console.log(`Trusted executable surface for ${c} (from package ${r.package}, artifact ${approvedIntegrity[c] || "?"}).`);
1249
+ if (r.skipped.length) console.log(`No executable surface (artifact integrity suffices, no approval needed): ${r.skipped.join(", ")}`);
1250
+ return;
1251
+ }
1252
+ if (all) { cmdFail("unknown-capability", `no installed package "${id}" — --all-capabilities takes a package identity`); return; }
1253
+ // Legacy standalone capability path.
1254
+ const manifest = capabilityManifest(id, dir);
1255
+ if (!manifest) { cmdFail("unknown-capability", `unknown capability "${id}"`); return; }
1256
+ const lock = readCapabilityLocks(dir)[manifest.capability];
1257
+ if (!lock) { cmdFail("invalid-lock", `${manifest.capability} is not locked in ${OATS_LOCK_FILE}`); return; }
1258
+ const integrity = capabilityIntegrity(manifest._dir);
1259
+ if (integrity !== lock.integrity) { cmdFail("integrity-drift", `integrity changed (${lock.integrity} → ${integrity}); reacquire explicitly before trusting`); return; }
1260
+ const { _file, ...clean } = lock;
1261
+ if (manifest.environment?.length) (JSON_MODE ? console.error : console.log)(`Requested launch environment: ${manifest.environment.join(", ")}`);
1262
+ try { writeCapabilityLock(dirname(_file), manifest.capability, { ...clean, trustedExecutables: true }); }
1263
+ catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
1264
+ if (JSON_MODE) { jsonOk({ capability: manifest.capability, integrity, legacy: true, environment: [...(manifest.environment || [])] }); return; }
1265
+ console.log(`Trusted executable surface for ${manifest.capability} at ${integrity}.`);
1266
+ }
1267
+
1268
+ // ---------- package config profiles (oats init --package / oats config diff) ----------
1269
+ /** Collect every value of a repeatable flag (e.g. --accept-requirement a --accept-requirement b).
1270
+ * A missing or flag-shaped value is a usage error — one E_USAGE envelope in JSON mode. */
1271
+ function flagAll(name) {
1272
+ const out = [];
1273
+ for (let i = 0; i < args.length; i++) {
1274
+ if (args[i] !== `--${name}`) continue;
1275
+ if (args[i + 1] && !args[i + 1].startsWith("--")) out.push(args[i + 1]);
1276
+ else (JSON_MODE ? jsonFail("E_USAGE", `--${name} needs a value`) : die(`--${name} needs a value`));
1277
+ }
1278
+ return out;
1279
+ }
1280
+
1281
+ /** Dependency-closure PROVIDER RECORDS for config-template validation.
1282
+ *
1283
+ * The flat model made this much smaller than its package-root ancestor: the
1284
+ * engine's lock reader walks raw lock-owning scopes rather than the config
1285
+ * chain, so a configless scope being initialized now sees its OWN lock without
1286
+ * the manual merge this used to need, and capability rows carry the provider
1287
+ * back-reference directly instead of package rows carrying capability lists.
1288
+ *
1289
+ * `staged` supplies the capabilities projected by THIS run's acquisition, which
1290
+ * are not locked yet when the pre-commit gate validates the template.
1291
+ * Returns { capabilities: Map<capabilityId, capabilityManifest|null> } — null
1292
+ * means lock-visible but not materialized, so layer agreement is unverifiable.
1293
+ */
1294
+ function dependencyClosureProviders(rootId, dir, staged = []) {
1295
+ const capabilities = new Map();
1296
+ let locks = { packages: {}, capabilities: {} };
1297
+ try { locks = readPackageLocks(dir); } catch { /* invalid lock surfaces at acquire */ }
1298
+
1299
+ const closure = new Set();
1300
+ const visit = (pkgId) => {
1301
+ if (!pkgId || closure.has(pkgId) || !Object.hasOwn(locks.packages, pkgId)) return;
1302
+ closure.add(pkgId);
1303
+ for (const dep of locks.packages[pkgId].dependencies || []) visit(dep);
1304
+ };
1305
+ visit(rootId);
1306
+
1307
+ for (const [capId, row] of Object.entries(locks.capabilities)) {
1308
+ if (!closure.has(row.package)) continue;
1309
+ let manifest = null;
1310
+ try {
1311
+ const artifact = installedCapabilityDir(row._level, capId);
1312
+ if (existsSync(join(artifact, "oats.json"))) manifest = JSON.parse(readFileSync(join(artifact, "oats.json"), "utf8"));
1313
+ } catch { /* unreadable artifact is a doctor problem, not a validation input */ }
1314
+ capabilities.set(capId, manifest);
1315
+ }
1316
+ // Same-run acquisition visibility: the root's own exports exist only in
1317
+ // staging while the gate runs, and a template that binds them must validate.
1318
+ // Preview rows carry the declared `layer` (null when none) — the minimum the
1319
+ // layer-agreement check needs — so a staged capability is represented by that
1320
+ // one field rather than a manifest the engine deliberately does not expose.
1321
+ for (const c of staged) capabilities.set(c.capability, c.manifest ?? { layer: c.layer ?? null });
1322
+ return { capabilities };
1323
+ }
1324
+
1325
+ /** Adopt a template from a package ALREADY locked at this scope: read its exact
1326
+ * locked templates, validate, then write config + base + metadata under the run
1327
+ * journal. Nothing is fetched beyond the locked source, and nothing is
1328
+ * re-acquired — the lock is already the truth about what is installed here. */
1329
+ function initPackageFromLock(packageId, dir, file, lockedRoot, configFlag, bail, note) {
1330
+ let locked, chosen;
1331
+ try { locked = readLockedConfigTemplates(dir, packageId); }
1332
+ catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); return; }
1333
+ try { chosen = selectConfigTemplate(locked.templates, configFlag, packageId); }
1334
+ catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); return; }
1335
+
1336
+ const errors = validateConfigTemplate(chosen, packageId, {
1337
+ dependencyProviders: dependencyClosureProviders(packageId, dir).capabilities,
1338
+ });
1339
+ if (errors.length) bail("E_TEMPLATE_INVALID", `config template "${chosen.template}" of package ${packageId} failed validation:\n - ${errors.join("\n - ")}`);
1340
+
1341
+ note(`Package ${packageId}${locked.version ? `@${locked.version}` : ""} is already locked here — adopting its config template "${chosen.template}" without re-acquiring.`);
1342
+
1343
+ let journal;
1344
+ try { journal = beginRunJournal(dir); }
1345
+ catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
1346
+ let adoption;
1347
+ try {
1348
+ adoption = writeAdoptedTemplate(dir, file, {
1349
+ package: packageId, template: chosen,
1350
+ root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
1351
+ });
1352
+ journal.finalize();
1353
+ } catch (e) {
1354
+ const report = journal.rollback();
1355
+ bail("E_ADOPT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
1356
+ return;
1357
+ }
1358
+
1359
+ const locks = readPackageLocks(dir);
1360
+ const capabilities = Object.entries(locks.capabilities).filter(([, c]) => c.package === packageId).map(([id]) => id);
1361
+ note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${packageId}:${chosen.template}`);
1362
+ if (JSON_MODE) {
1363
+ jsonOk({
1364
+ package: packageId, version: locked.version || null, commit: locked.commit || null,
1365
+ template: chosen.template, adopted: true, file, capabilities,
1366
+ adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
1367
+ contentIntegrity: chosen.contentIntegrity,
1368
+ lockFile: lockedRoot._file || join(dir, OATS_LOCK_FILE), lockedPackages: Object.keys(locks.packages),
1369
+ });
1370
+ return;
1371
+ }
1372
+ offerTmuxMouseScrolling();
1373
+ }
1374
+
1375
+ /** oats init --package <source> [--config <name>]: acquire a package AND adopt
1376
+ * one of its config templates as this scope's local config.
1377
+ *
1378
+ * This command is adoption, not an install alias: `oats install <package>`
1379
+ * installs capabilities and applies no template, while this one always adopts
1380
+ * exactly one — the named template, else the single marked default, else the
1381
+ * only one. Several unmarked templates are E_TEMPLATE_AMBIGUOUS and a package
1382
+ * with none is E_NO_TEMPLATES; both refuse inside the pre-commit gate, so the
1383
+ * scope is never touched.
1384
+ *
1385
+ * Transaction shape: the outer journal opens BEFORE acquisition, so its
1386
+ * snapshot is the pre-command state. A gate refusal or acquire failure rolls it
1387
+ * back (the engine is zero-mutation there, so this mainly closes the backup); a
1388
+ * failure while writing the adoption files rolls back the lock, the capability
1389
+ * store, the ignore file, the config and the adopted base together — the
1390
+ * newly acquired state disappears and every pre-existing byte returns.
1391
+ * finalize() runs only after every adoption write has succeeded.
1392
+ *
1393
+ * JSON mode: one compact envelope. CLI codes E_TEMPLATE_INVALID /
1394
+ * E_TEMPLATE_AMBIGUOUS / E_TEMPLATE_NOT_FOUND / E_NO_TEMPLATES; engine codes
1395
+ * pass through verbatim. Fully noninteractive. */
1396
+ function initPackage(src, dir, file) {
1397
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
1398
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1399
+ const configFlag = flag("config");
1400
+ if (configFlag === true) bail("E_USAGE", "--config needs a template name");
1401
+
1402
+ let chosen = null; // the selected+validated template descriptor
1403
+ let rootRecord = null; // the acquired root package row
1404
+ let projected = []; // projected capability rows
1405
+
1406
+ /** The pre-commit gate. Everything that can refuse refuses HERE, while the
1407
+ * scope is still untouched. It THROWS rather than exiting: the process-exit
1408
+ * path would strand the journal's backup, and the engine propagates a gate
1409
+ * throw unchanged with nothing mutated. */
1410
+ const assertCommittable = (preview) => {
1411
+ rootRecord = preview.packages.find((p) => p.package === preview.root) || null;
1412
+ projected = preview.capabilities || [];
1413
+ const templates = (preview.configTemplates || []).filter((t) => t.package === preview.root);
1414
+
1415
+ note(`Package ${preview.root}${rootRecord?.version ? `@${rootRecord.version}` : ""} — installs ${projected.length} capability(ies): ${projected.map((c) => c.capability).join(", ") || "(none)"}`);
1416
+ const executable = projected.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length);
1417
+ if (executable.length) note(` executable surfaces needing separate approval: ${executable.map((c) => c.capability).join(", ")} (\`oats trust <id>\`)`);
1418
+
1419
+ chosen = selectConfigTemplate(templates, configFlag, preview.root); // throws typed codes
1420
+ // Every check now refuses PRE-COMMIT, layer agreement included: preview
1421
+ // capability rows carry the declared layer, so a template binding a slot to
1422
+ // one of the package's own staged capabilities is validated here, with the
1423
+ // scope untouched and no rollback needed.
1424
+ const errors = validateConfigTemplate(chosen, preview.root, {
1425
+ dependencyProviders: dependencyClosureProviders(preview.root, dir, projected).capabilities,
1426
+ });
1427
+ if (errors.length) {
1428
+ const e = new Error(`config template "${chosen.template}" of package ${preview.root} failed validation:\n - ${errors.join("\n - ")}`);
1429
+ e.code = "E_TEMPLATE_INVALID";
1430
+ throw e;
1431
+ }
1432
+ note(`Config template "${chosen.template}"${chosen.description ? `: ${chosen.description}` : ""} — validated (${chosen.contentIntegrity})`);
1433
+ note(` it becomes YOUR local ${shortPath(file)}: every copied setting is editable, and package updates never rewrite it.`);
1434
+ };
1435
+
1436
+ // Opened BEFORE acquisition: a snapshot taken afterwards would record the new
1437
+ // lock, artifacts and ignore bytes as the "pre-existing" state and could
1438
+ // never undo them.
1439
+ // An id already locked at this scope is adopted from the LOCK, not
1440
+ // re-acquired: its exact source/commit is already pinned, the capabilities are
1441
+ // already materialized, and going to the network (or the catalog) to re-fetch
1442
+ // what the lock already names would be a different package than the one
1443
+ // installed here. This is the `oats init --package <id>` half of the documented
1444
+ // <id|path|git-url> form.
1445
+ let lockedRoot = null;
1446
+ try { lockedRoot = readPackageLocks(dir).packages[src] || null; }
1447
+ catch { /* an invalid lock surfaces with its own typed code below */ }
1448
+ if (lockedRoot) { initPackageFromLock(src, dir, file, lockedRoot, configFlag, bail, note); return; }
1449
+
1450
+ // Constructed inside its own guard: a journal that cannot be built (a symlink
1451
+ // component, an unreadable snapshot, a backup that would land inside the
1452
+ // scope) must still leave the command with exactly one JSON envelope. There
1453
+ // is nothing to roll back yet, so its typed code goes straight to bail.
1454
+ let journal;
1455
+ try { journal = beginRunJournal(dir); }
1456
+ catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
1457
+
1458
+ /** Undo the run, then report. `code` is the engine's verbatim code for
1459
+ * acquisition failures and a stable CLI code for our own write failures — a
1460
+ * raw errno like ENOTDIR is not a contract automation can branch on. */
1461
+ const abort = (e, code) => {
1462
+ const report = journal.rollback();
1463
+ const detail = code === "E_ADOPT_FAILED" ? `adopting the config template failed after the package was installed: ${e.message}` : e.message;
1464
+ bail(code || e.code || "E_INIT_FAILED", report.complete ? detail : `${detail} — ${report.summary}`);
1465
+ };
1466
+
1467
+ let acq;
1468
+ try { acq = acquirePackage(dir, src, { assertCommittable }); }
1469
+ catch (e) { abort(e); return; }
1470
+
1471
+ note(`Acquired + locked: ${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")} → ${shortPath(acq.lockFile)}`);
1472
+ const capabilities = acq.capabilities.map((c) => c.capability);
1473
+
1474
+ // DEFENCE IN DEPTH, not the primary check. The gate above already validated
1475
+ // every binding against the preview's declared layers; this re-checks them
1476
+ // against the manifests actually written to disk, so a projection that
1477
+ // disagreed with its own preview cannot leave a broken config behind. It
1478
+ // should never fire — and if it does, the journal restores the scope
1479
+ // completely, so nothing of the run survives.
1480
+ const materialized = new Map();
1481
+ for (const c of acq.capabilities) {
1482
+ let manifest = null;
1483
+ try { manifest = JSON.parse(readFileSync(join(installedCapabilityDir(dir, c.capability), "oats.json"), "utf8")); }
1484
+ catch { /* unreadable artifact is reported by doctor; leave it unverifiable */ }
1485
+ materialized.set(c.capability, manifest);
1486
+ }
1487
+ for (const [id, m] of dependencyClosureProviders(acq.root, dir).capabilities) if (!materialized.has(id)) materialized.set(id, m);
1488
+ const lateErrors = validateConfigTemplate(chosen, acq.root, { dependencyProviders: materialized });
1489
+ if (lateErrors.length) {
1490
+ const e = new Error(`config template "${chosen.template}" of package ${acq.root} failed validation:\n - ${lateErrors.join("\n - ")}`);
1491
+ e.code = "E_TEMPLATE_INVALID";
1492
+ abort(e);
1493
+ return;
1494
+ }
1495
+
1496
+ let adoption;
1497
+ try {
1498
+ adoption = writeAdoptedTemplate(dir, file, { package: acq.root, template: chosen, root: rootRecord });
1499
+ journal.finalize();
1500
+ } catch (e) { abort(e, "E_ADOPT_FAILED"); return; }
1501
+
1502
+ note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${acq.root}:${chosen.template}`);
1503
+ note(`Recorded the adopted base at ${shortPath(adoption.baseFile)} — commit it; \`oats config diff\` and \`oats config sync\` compare against it.`);
1504
+ if (JSON_MODE) {
1505
+ jsonOk({
1506
+ package: acq.root, version: rootRecord?.version || null, commit: rootRecord?.commit || null,
1507
+ template: chosen.template, adopted: true, file, capabilities,
1508
+ adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
1509
+ contentIntegrity: chosen.contentIntegrity,
1510
+ lockFile: acq.lockFile, lockedPackages: acq.installed.map((p) => p.package),
1511
+ });
1512
+ return;
1513
+ }
1514
+ offerTmuxMouseScrolling();
1515
+ }
1516
+
1517
+ /** `oats config <diff|sync|adopt>` — the guided three-way template lane.
1518
+ *
1519
+ * All three share one comparison: the recorded adopted base, the current local
1520
+ * oats-config.yaml, and the selected template read from the CURRENT EXACT LOCK.
1521
+ * Only `sync` and `adopt` mutate, and both present the complete plan first.
1522
+ */
1523
+ function configCmd() {
1524
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
1525
+ const sub = args[1];
1526
+ if (!["diff", "sync", "adopt"].includes(sub)) {
1527
+ bail("E_USAGE", "usage: oats config <diff|sync|adopt> [--config <template>] [--dir <dir>] [--json]");
1528
+ }
1529
+ const dir = resolve(flag("dir") || process.cwd());
1530
+ const file = join(dir, "oats-config.yaml");
1531
+ if (!existsSync(file)) bail("E_NO_CONFIG", `no oats-config.yaml at ${shortPath(dir)} — adopt one with \`oats init --package <source> --config <name>\``);
1532
+ const localText = readFileSync(file, "utf8");
1533
+
1534
+ let adopted;
1535
+ try { adopted = readAdoptedTemplate(dir); }
1536
+ catch (e) { bail(e.code || "E_ADOPTION_INVALID", e.message); }
1537
+
1538
+ // `adopt` switches base; the others need an existing one.
1539
+ const adoptTarget = sub === "adopt" ? args[2] : undefined;
1540
+ if (sub === "adopt" && (!adoptTarget || adoptTarget.startsWith("--"))) {
1541
+ bail("E_USAGE", "usage: oats config adopt <package> [--config <template>] — the package must already be installed at this scope");
1542
+ }
1543
+ if (sub !== "adopt" && !adopted) {
1544
+ bail("E_NO_ADOPTED_BASE", `${shortPath(file)} was not adopted from a config template, so there is no recorded base to compare against — adopt one with \`oats config adopt <package> --config <name>\``);
1545
+ }
1546
+
1547
+ const packageId = sub === "adopt" ? adoptTarget : adopted.package;
1548
+ const templateFlag = flag("config");
1549
+ if (templateFlag === true) bail("E_USAGE", "--config needs a template name");
1550
+ const wanted = templateFlag || (sub === "adopt" ? undefined : adopted.template);
1551
+
1552
+ // Exact locked read — never the network-free guess, never a package root.
1553
+ let locked;
1554
+ try { locked = readLockedConfigTemplates(dir, packageId); }
1555
+ catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); }
1556
+ let chosen;
1557
+ try { chosen = selectConfigTemplate(locked.templates, wanted, packageId); }
1558
+ catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); }
1559
+
1560
+ // Switching base rebases the ONE local config against the new template.
1561
+ //
1562
+ // With no adopted base there is NO common ancestor, and pretending the local
1563
+ // file is one is the dangerous answer: a three-way merge whose base equals
1564
+ // local classifies every difference as upstream-only, so a first adopt would
1565
+ // silently replace a handcrafted config wholesale — no conflicts, no consent,
1566
+ // no preview of what was lost. An EMPTY base states the truth instead: every
1567
+ // existing local byte is local work, and anything the template also wants to
1568
+ // put there is a conflict the operator must resolve explicitly.
1569
+ const baseText = adopted ? adopted.baseText : "";
1570
+ const plan = planConfigMerge(baseText, localText, chosen.content);
1571
+
1572
+ const describe = (r) => ({
1573
+ id: r.id, kind: r.kind, recommended: r.recommended, digest: r.digest,
1574
+ startLine: r.local.start + 1, lines: r.local.end - r.local.start,
1575
+ base: r.base.text, local: r.local.text, package: r.template.text,
1576
+ });
1577
+
1578
+ if (sub === "diff") {
1579
+ if (JSON_MODE) {
1580
+ jsonOk({
1581
+ package: packageId, template: chosen.template, version: locked.version || null, commit: locked.commit || null,
1582
+ file, adoptedBase: adopted?.baseFile || null, contentIntegrity: chosen.contentIntegrity,
1583
+ clean: plan.clean, counts: plan.counts, conflicts: plan.conflicts,
1584
+ regions: plan.regions.map(describe), planDigest: plan.planDigest,
1585
+ });
1586
+ return;
1587
+ }
1588
+ console.log(`oats config diff — ${shortPath(file)} vs ${packageId}:${chosen.template}${locked.version ? `@${locked.version}` : ""} (report only; nothing is written)\n`);
1589
+ if (!plan.regions.length) { console.log("No differences: your config, the adopted base, and the package template agree."); return; }
1590
+ for (const r of plan.regions) renderMergeRegion(r);
1591
+ console.log(`\n${plan.counts.upstream} upstream-only, ${plan.counts.local} local-only, ${plan.counts.conflict} conflict(s), ${plan.counts.agreed} already agreed.`);
1592
+ console.log(plan.clean
1593
+ ? "Apply the upstream changes with `oats config sync` (local-only edits are kept)."
1594
+ : "`oats config sync` needs an explicit choice for each conflict — it will never pick one for you.");
1595
+ return;
1596
+ }
1597
+
1598
+ // ---- sync / reset / adopt: everything below MUTATES, so plan first ----
1599
+
1600
+ const decisions = {};
1601
+ for (const spec of flagAll("accept")) {
1602
+ const m = /^([^=]+)=(local|package)$/.exec(spec);
1603
+ if (!m) bail("E_USAGE", `--accept takes <regionId>=<local|package>, got "${spec}"`);
1604
+ decisions[m[1]] = m[2];
1605
+ }
1606
+ const assumeYes = args.includes("--yes");
1607
+ const isReset = args.includes("--reset");
1608
+
1609
+ // The recoverable backup survives a SUCCESSFUL run: the run journal is for
1610
+ // undoing failures, this is for the adopter who changes their mind.
1611
+ const backupFile = `${file}.bak`;
1612
+
1613
+ if (isReset) {
1614
+ // Reset previews everything it will destroy, then demands explicit consent.
1615
+ const lost = plan.regions.filter((r) => r.kind === "local" || r.kind === "conflict");
1616
+ if (JSON_MODE || !process.stdin.isTTY) {
1617
+ if (!assumeYes) {
1618
+ bail("E_RESET_NOT_CONFIRMED", `oats config sync --reset would discard ${lost.length} local change region(s) in ${shortPath(file)} and replace it with ${packageId}:${chosen.template} verbatim — pass --yes to accept that noninteractively`);
1619
+ }
1620
+ } else if (!assumeYes) {
1621
+ console.log(`This DISCARDS ${lost.length} local change region(s) in ${shortPath(file)}:\n`);
1622
+ for (const r of lost) renderMergeRegion(r);
1623
+ const answer = promptLine(`Type the word "discard" to replace it with ${packageId}:${chosen.template}: `);
1624
+ if (answer.trim() !== "discard") bail("E_RESET_NOT_CONFIRMED", "reset cancelled — nothing was changed");
1625
+ }
1626
+ const journal = openJournal(dir, bail);
1627
+ try {
1628
+ // NEVER copyFileSync onto a fixed backup path: it opens the destination
1629
+ // for write and therefore FOLLOWS it, so a pre-planted
1630
+ // `oats-config.yaml.bak` symlink would redirect this copy onto whatever it
1631
+ // points at. The atomic form replaces the entry itself.
1632
+ if (existsSync(file)) copyFileAtomic(file, backupFile);
1633
+ writeFileAtomic(file, chosen.content);
1634
+ recordAdoption(dir, file, packageId, chosen, locked, adopted);
1635
+ journal.finalize();
1636
+ } catch (e) { abortRun(journal, e, bail); return; }
1637
+ if (JSON_MODE) { jsonOk({ action: "reset", package: packageId, template: chosen.template, file, backup: backupFile, discardedRegions: lost.length, contentIntegrity: chosen.contentIntegrity }); return; }
1638
+ console.log(`Reset ${shortPath(file)} to ${packageId}:${chosen.template} verbatim. Previous contents saved at ${shortPath(backupFile)}.`);
1639
+ return;
1640
+ }
1641
+
1642
+ // sync / adopt share the three-way apply.
1643
+ const unresolved = plan.conflicts.filter((id) => !Object.hasOwn(decisions, id));
1644
+ if (unresolved.length) {
1645
+ if (JSON_MODE || !process.stdin.isTTY) {
1646
+ bail("E_SYNC_AMBIGUOUS", `${unresolved.length} conflict(s) need an explicit choice (${unresolved.join(", ")}) — pass --accept <id>=<local|package> for each; this command will never choose for you`);
1647
+ }
1648
+ for (const id of unresolved) {
1649
+ const region = plan.regions.find((r) => r.id === id);
1650
+ renderMergeRegion(region);
1651
+ const answer = promptLine(`[${id}] keep (l)ocal or take (p)ackage? `).trim().toLowerCase();
1652
+ if (answer === "l" || answer === "local") decisions[id] = "local";
1653
+ else if (answer === "p" || answer === "package") decisions[id] = "package";
1654
+ else bail("E_SYNC_AMBIGUOUS", `no choice made for ${id} — nothing was changed`);
1655
+ }
1656
+ }
1657
+
1658
+ let merged;
1659
+ try { merged = applyConfigMerge(localText, plan, decisions); }
1660
+ catch (e) { bail(e.code || "E_SYNC_FAILED", e.message); return; }
1661
+
1662
+ // Advancing the recorded base is the POINT of a sync, not a side effect of
1663
+ // changing bytes. Deciding "keep local" on every conflict changes nothing on
1664
+ // disk, but the decision must still be recorded — otherwise the base stays
1665
+ // behind and the identical conflict is re-presented on every future sync,
1666
+ // forever. So "nothing to do" means nothing applied AND the base already at
1667
+ // this exact template.
1668
+ const baseIsCurrent = adopted?.package === packageId
1669
+ && adopted?.template === chosen.template
1670
+ && adopted?.baseText === chosen.content;
1671
+ if (!merged.applied.length && baseIsCurrent) {
1672
+ if (JSON_MODE) { jsonOk({ action: sub, package: packageId, template: chosen.template, file, changed: false, baseAdvanced: false, applied: [], backup: null }); return; }
1673
+ console.log(`Nothing to do: ${shortPath(file)} and the recorded base are already at ${packageId}:${chosen.template}.`);
1674
+ return;
1675
+ }
1676
+
1677
+ if (!JSON_MODE) {
1678
+ console.log(`Plan for ${shortPath(file)} vs ${packageId}:${chosen.template}:`);
1679
+ for (const a of merged.applied) console.log(` [${a.id}] ${a.kind} → ${a.choice}`);
1680
+ console.log("");
1681
+ }
1682
+
1683
+ const changed = merged.text !== localText;
1684
+ const journal = openJournal(dir, bail);
1685
+ try {
1686
+ // Back up only when bytes actually change — a backup identical to the file
1687
+ // it shadows is noise the adopter has to reason about later.
1688
+ if (changed) copyFileAtomic(file, backupFile);
1689
+ if (changed) writeFileAtomic(file, merged.text);
1690
+ recordAdoption(dir, file, packageId, chosen, locked, adopted);
1691
+ journal.finalize();
1692
+ } catch (e) { abortRun(journal, e, bail); return; }
1693
+
1694
+ if (JSON_MODE) {
1695
+ jsonOk({
1696
+ action: sub, package: packageId, template: chosen.template, file, changed,
1697
+ baseAdvanced: true, applied: merged.applied, backup: changed ? backupFile : null,
1698
+ adoptedBase: join(adoptedTemplateDir(dir, packageId, chosen.template), "oats-config.yaml"),
1699
+ contentIntegrity: chosen.contentIntegrity,
1700
+ });
1701
+ return;
1702
+ }
1703
+ if (changed) console.log(`Applied ${merged.applied.length} change region(s) to ${shortPath(file)}; previous contents saved at ${shortPath(backupFile)}.`);
1704
+ else console.log(`No bytes changed in ${shortPath(file)} — you kept every local choice.`);
1705
+ console.log(`Adopted base advanced to ${packageId}:${chosen.template}, so these decisions will not be asked again. Local edits outside the applied regions are untouched.`);
1706
+ }
1707
+
1708
+ /** Open the run journal with the command's one-envelope guarantee intact. */
1709
+ function openJournal(dir, bail) {
1710
+ try { return beginRunJournal(dir); }
1711
+ catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); throw e; }
1712
+ }
1713
+
1714
+ /** Undo a failed config mutation and report truthfully. */
1715
+ function abortRun(journal, e, bail) {
1716
+ const report = journal.rollback();
1717
+ bail(e.code || "E_CONFIG_WRITE_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
1718
+ }
1719
+
1720
+ /** Write the adopted base + metadata for the template just synced against, and
1721
+ * retire any previously adopted base so exactly one survives. */
1722
+ function recordAdoption(dir, file, packageId, chosen, locked, previous) {
1723
+ const written = writeAdoptedTemplate(dir, file, {
1724
+ package: packageId, template: chosen,
1725
+ root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
1726
+ }, { writeConfig: false });
1727
+ if (previous && (previous.package !== packageId || previous.template !== chosen.template)) {
1728
+ rmSync(previous.dir, { recursive: true, force: true });
1729
+ const parent = dirname(previous.dir);
1730
+ try { if (!readdirSync(parent).length) rmSync(parent, { recursive: true, force: true }); } catch { /* sibling templates remain */ }
1731
+ }
1732
+ return written;
1733
+ }
1734
+
1735
+ /** Read one line from the terminal (human confirmation paths only). */
1736
+ function promptLine(question) {
1737
+ process.stdout.write(question);
1738
+ const buf = Buffer.alloc(1024);
1739
+ let read = 0;
1740
+ try { read = readSync(0, buf, 0, buf.length, null); } catch { return ""; }
1741
+ return buf.subarray(0, read).toString("utf8").replace(/\n.*$/s, "");
1742
+ }
1743
+
1744
+ /** One merge region, rendered for a human deciding what to do about it. */
1745
+ function renderMergeRegion(r) {
1746
+ const label = {
1747
+ upstream: "UPSTREAM ONLY — the package template changed this; your config did not",
1748
+ local: "LOCAL ONLY — you changed this; the package template did not (it stays)",
1749
+ conflict: "CONFLICT — both changed this; an explicit choice is required",
1750
+ agreed: "ALREADY AGREED — you and the package made the same change",
1751
+ }[r.kind];
1752
+ console.log(`[${r.id}] line ${r.local.start + 1}: ${label}`);
1753
+ const block = (title, text) => {
1754
+ if (!text) { console.log(` ${title}: (nothing)`); return; }
1755
+ for (const line of text.replace(/\n$/, "").split("\n")) console.log(` ${title}: ${line}`);
1756
+ };
1757
+ if (r.kind !== "local") block("package", r.template.text);
1758
+ if (r.kind !== "upstream") block("yours ", r.local.text);
1759
+ console.log("");
1760
+ }
1761
+
1762
+ /** oats list — installed packages, exported capabilities, scopes. */
1763
+ function listCmd() {
1764
+ const dir = dirFlag();
1765
+ // FAIL-CLOSED (maintainer finding 3): list RAISES on invalid locks — an
1766
+ // invalid lock must never render as usable/absent data.
1767
+ let pkgs, locks;
1768
+ try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); }
1769
+ catch (e) { JSON_MODE ? jsonFail(e.code || "invalid-lock", e.message || e) : die(e.message); return; }
1770
+ // Packages are TRANSPORT; capabilities are what is installed. So the listing
1771
+ // is capability-first: every row names its own provider, artifact, integrity,
1772
+ // trust and health, and the package rows keep only what the transport itself
1773
+ // pins. Trust is per capability — there is no package-level approval to list.
1774
+ const capabilities = [];
1775
+ for (const p of pkgs) {
1776
+ const rows = levelRows(locks, p.level);
1777
+ for (const c of p.capabilities) {
1778
+ const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
1779
+ capabilities.push({
1780
+ capability: c.id, version: c.version || null, package: p.package, level: p.level,
1781
+ path: c.path || null, dir: h.dir, integrity: c.integrity || null,
1782
+ installedIntegrity: h.integrity ?? null,
1783
+ layer: c.manifest?.layer || null, trusted: c.trusted === true, installed: c.installed,
1784
+ executableSurface: {
1785
+ commands: Object.keys(c.manifest?.commands || {}),
1786
+ hooks: Object.keys(c.manifest?.hooks || {}),
1787
+ environment: [...(c.manifest?.environment || [])],
1788
+ },
1789
+ status: h.status, code: h.code, detail: h.detail,
1790
+ });
1791
+ }
1792
+ }
1793
+ if (JSON_MODE) {
1794
+ jsonOk({
1795
+ packages: pkgs.map((p) => ({ package: p.package, version: p.version, level: p.level, source: p.source || null, path: p.path || null, commit: p.commit || null, integrity: p.integrity || null, locked: p.locked, dependencies: p.dependencies, capabilities: p.capabilities.map((c) => c.id) })),
1796
+ capabilities,
1797
+ legacy: locks.legacy.map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion, capabilities: Object.keys(l.capabilities) })),
1798
+ });
1799
+ return;
1800
+ }
1801
+ if (!pkgs.length) console.log("No installed packages in this config chain.");
1802
+ const byPackage = new Map();
1803
+ for (const c of capabilities) {
1804
+ if (!byPackage.has(c.package)) byPackage.set(c.package, []);
1805
+ byPackage.get(c.package).push(c);
1806
+ }
1807
+ for (const p of pkgs) {
1808
+ console.log(`${p.package}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]${p.locked ? "" : " UNLOCKED (no lock entry — reacquire)"}`);
1809
+ if (p.source) console.log(` source: ${p.source} path: ${p.path || "?"} commit: ${p.commit || "?"}`);
1810
+ for (const c of byPackage.get(p.package) || []) {
1811
+ const executable = c.executableSurface.commands.length || c.executableSurface.hooks.length || c.executableSurface.environment.length;
1812
+ const trust = executable ? (c.trusted ? " [trusted]" : " [executable — needs oats trust]") : "";
1813
+ console.log(` capability ${c.capability}${c.layer ? ` layer: ${c.layer}` : ""}${trust}`);
1814
+ // A capability whose bytes or provenance disagree with the lock is named
1815
+ // as broken HERE — never rendered as an ordinary usable row.
1816
+ if (c.status !== "ok" && c.status !== "untrusted") console.log(` ${c.status.toUpperCase()}: ${c.detail}`);
1817
+ }
1818
+ if (p.dependencies.length) console.log(` depends on: ${p.dependencies.join(", ")}`);
1819
+ }
1820
+ for (const l of locks.legacy) console.log(`Legacy capability locks (lockfileVersion ${l.lockfileVersion ?? 1}) in ${shortPath(l.file)}: ${Object.keys(l.capabilities).join(", ")} — \`oats migrate\` maps them to packages`);
1821
+ }
1822
+
1823
+ /** oats remove <package> — refuses while config or dependent packages reference it. */
1824
+ function removeCmd() {
1825
+ const id = args[1];
1826
+ if (!id || id.startsWith("--")) JSON_MODE ? jsonFail("E_USAGE", "usage: oats remove <package> [--dir <dir>]") : die("usage: oats remove <package> [--dir <dir>]");
1827
+ const dir = dirFlag();
1828
+ let r;
1829
+ try { r = removePackage(dir, id); } catch (e) { cmdFail(e.code || "remove-blocked", e.message || e); return; }
1830
+ if (JSON_MODE) { jsonOk(r); return; }
1831
+ // There is no package directory to name — a package is transport, and what
1832
+ // actually leaves the disk is its materialized capability artifacts.
1833
+ console.log(`Removed package ${r.package} from ${shortPath(r.lockFile)}.`);
1834
+ console.log(r.capabilities.length
1835
+ ? ` capabilities de-materialized: ${r.capabilities.join(", ")}`
1836
+ : " it supplied no capabilities at this scope.");
1837
+ }
1838
+
1839
+ /** The team boundary a guided migration walks, when the scope declares one.
1840
+ * A config the kernel refuses to resolve is not a reason to abort a migration
1841
+ * that only reads locks — discovery falls back to the explicit scope and says so. */
1842
+ function migrationTeamScope(dir, warnings) {
1843
+ try { return resolveOatsConfig(dir)?.team?.scope || undefined; }
1844
+ catch (e) { warnings.push(`team boundary not resolved from ${shortPath(dir)} (${e.message}) — discovery covers this scope and its lock-owning ancestors only`); return undefined; }
1845
+ }
1846
+
1847
+ const migratePlanRow = (s) => ({
1848
+ capability: s.capabilityId, action: s.action,
1849
+ package: s.package?.id || null, spec: s.package?.spec || null, via: s.package?.via || null,
1850
+ migratesTo: s.migratesTo || null,
1851
+ source: s.v1?.source || null, reason: s.reason || null, note: s.note || null,
1852
+ });
1853
+
1854
+ /** `oats migrate --official` / `--recursive` — the guided existing-user upgrade.
1855
+ *
1856
+ * Plans EVERY visible lock-owning scope first (deterministic, side-effect
1857
+ * free), prints the complete per-scope plan, then applies scope by scope. Each
1858
+ * scope keeps the engine's transactional guarantee on its own: one scope's
1859
+ * failure leaves that scope byte-identical, never stops the others from being
1860
+ * reported truthfully, and makes the aggregate result nonzero. */
1861
+ function guidedMigrateCmd({ dir, dryRun, official, recursive }) {
1862
+ const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1863
+ const opts = official ? { official: true } : {};
1864
+ const warnings = [];
1865
+ const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
1866
+ let scopes;
1867
+ try {
1868
+ scopes = recursive
1869
+ ? discoverMigrationScopes(dir, { teamScope })
1870
+ : (existsSync(join(dir, OATS_LOCK_FILE)) ? [resolve(dir)] : []);
1871
+ } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
1872
+ // Un-migrated OAS scopes are invisible to lock discovery (they own no
1873
+ // oats-lock.json), and silence would read as "nothing to migrate" — the
1874
+ // exact false success this probe exists to prevent (aweb-abfy.1).
1875
+ const oasScopes = recursive ? discoverOasScopes(dir, { teamScope }) : detectOasScopes(dir);
1876
+
1877
+ // ---- plan every scope BEFORE touching any of them ----
1878
+ const planned = [];
1879
+ for (const scope of scopes) {
1880
+ const file = join(scope, OATS_LOCK_FILE);
1881
+ try {
1882
+ const { plan, warnings: w } = migrateLegacyLock(scope, opts);
1883
+ const held = plan.filter((s) => s.action === "hold");
1884
+ const acquire = plan.filter((s) => s.action === "acquire");
1885
+ const formatOnly = plan.some((s) => s.action === "convert-format");
1886
+ const keep = plan.filter((s) => s.action === "retain" || s.action === "manual");
1887
+ // Both modes are ALL-OR-NOTHING: a v2 lock has no residue container, so a
1888
+ // scope converts completely or stays v1 in full. `keep` entries therefore
1889
+ // make a scope unconvertible rather than partially convertible — apply
1890
+ // refuses it, and the plan says so rather than promising "ready".
1891
+ // Official mode also never rewrites a scope it has no official work in.
1892
+ const convertible = acquire.length || formatOnly || (!official && plan.length);
1893
+ const status = held.length ? "held"
1894
+ : (convertible && !keep.length) ? "ready"
1895
+ : convertible ? "blocked"
1896
+ : "nothing";
1897
+ planned.push({ scope, file, status, plan, acquire, keep, held, warnings: w });
1898
+ } catch (e) {
1899
+ planned.push({ scope, file, status: "failed", plan: [], acquire: [], keep: [], held: [], warnings: [], error: { code: e.code || "invalid-lock", message: String(e.message || e) } });
1900
+ }
1901
+ }
1902
+ const planRows = planned.map((p) => ({
1903
+ level: p.scope, levelKind: levelOf(p.scope), file: p.file, status: p.status,
1904
+ plan: p.plan.map(migratePlanRow), warnings: p.warnings, error: p.error || null,
1905
+ }));
1906
+
1907
+ const actionable = planned.filter((p) => p.status === "ready" || p.status === "format-only");
1908
+ out(`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} — ${scopes.length} lock-owning scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
1909
+ for (const w of warnings) out(`WARNING: ${w}`);
1910
+ if (!scopes.length) out(oasScopes.length
1911
+ ? " (no oats-lock.json found — but this is NOT an empty scope: un-migrated OAS files are present, see below)"
1912
+ : " (no oats-lock.json found — nothing to migrate)");
1913
+ for (const f of oasScopes) {
1914
+ out(`\n ${shortPath(f.dir)} UN-MIGRATED OAS SCOPE (${f.files.join(", ")})`);
1915
+ out(` HELD ${OAS_SCOPE_REMEDY}`);
1916
+ }
1917
+ for (const p of planned) {
1918
+ out(`\n ${shortPath(p.scope)} [${levelOf(p.scope)}] ${shortPath(p.file)}`);
1919
+ if (p.status === "failed") { out(` ERROR ${p.error.message} [${p.error.code}]`); continue; }
1920
+ for (const s of p.plan) {
1921
+ if (s.action === "convert-format") out(` format ${s.note}`);
1922
+ else if (s.action === "acquire") out(` migrate ${s.capabilityId} → package ${s.package.id || s.package.spec}${s.migratesTo ? ` (catalog alias: package ${s.package.id} exports ${s.migratesTo}, replacing ${s.capabilityId})` : s.package.via === "alias" ? ` (catalog alias: package ${s.package.id} exports ${s.capabilityId})` : s.package.via === "identity" ? " (official catalog)" : ""}`);
1923
+ else if (s.action === "hold") out(` HELD ${s.capabilityId} — ${s.reason}`);
1924
+ else out(` keep ${s.capabilityId}${s.v1?.source ? ` (${s.v1.source})` : ""} — not converted, entry kept unchanged`);
1925
+ }
1926
+ if (p.status === "nothing") out(" (nothing to migrate at this scope)");
1927
+ if (p.status === "blocked") {
1928
+ out(` BLOCKED this scope mixes convertible work with ${p.keep.length} entr${p.keep.length === 1 ? "y" : "ies"} that must stay lockfileVersion 1`);
1929
+ out(" a capability-materialization lock has no place for them, so converting the rest would drop them — the WHOLE scope stays v1 and keeps working");
1930
+ }
1931
+ if (p.status === "ready") {
1932
+ const renames = p.plan.filter((s) => s.migratesTo);
1933
+ if (renames.length) out(` config ${shortPath(join(p.scope, "oats-config.yaml"))} is NOT rewritten — but renamed ids must be updated by hand after applying: ${renames.map((s) => `${s.capabilityId} → ${s.migratesTo}`).join(", ")}`);
1934
+ else out(` config ${shortPath(join(p.scope, "oats-config.yaml"))} is NOT rewritten — capability ids, layers, targets, settings, exclusions and overrides stay valid (packages export the same ids)`);
1935
+ out(" trust executable approvals are NOT carried over — they are re-earned after migrating (exact commands below)");
1936
+ }
1937
+ for (const w of p.warnings) out(` WARNING: ${w}`);
1938
+ }
1939
+
1940
+ const result = {
1941
+ mode: official ? "official" : "generic", recursive, dryRun,
1942
+ boundary: resolve(dir), scopes: planRows, oasScopes, oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
1943
+ trust: [], requirements: [], nextCommands: [], warnings,
1944
+ };
1945
+ if (dryRun) {
1946
+ const failed = planned.filter((p) => p.status === "failed");
1947
+ const held = planned.filter((p) => p.status === "held");
1948
+ result.nextCommands = actionable.length ? [`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`] : [];
1949
+ // A held or unplannable scope is NOT a ready migration: the dry run says so
1950
+ // with a nonzero result in both modes, so automation can never read
1951
+ // "planned successfully" as "this deployment can migrate now"
1952
+ // (reviewer-90dbb36). The complete plan travels under error.details.
1953
+ const mixed = planned.filter((p) => p.status === "blocked");
1954
+ const blocked = [
1955
+ ...(held.length ? [`${held.length} scope${held.length > 1 ? "s" : ""} held (no official package mapping yet)`] : []),
1956
+ ...(mixed.length ? [`${mixed.length} scope${mixed.length > 1 ? "s" : ""} blocked (entries that must stay lockfileVersion 1)`] : []),
1957
+ ...(failed.length ? [`${failed.length} scope${failed.length > 1 ? "s" : ""} could not be planned`] : []),
1958
+ ...(oasScopes.length ? [`${oasScopes.length} un-migrated OAS scope${oasScopes.length > 1 ? "s" : ""} detected (${oasScopes.map((f) => shortPath(f.dir)).join(", ")}) — no oas-* name is recognized and there is no automatic path yet; see docs/migration-from-oas.md`] : []),
1959
+ ];
1960
+ if (JSON_MODE) {
1961
+ if (blocked.length) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_MIGRATE_FAILED", message: `${blocked.join("; ")} (${actionable.length} ready)`, details: result } })); process.exit(1); }
1962
+ jsonOk(result);
1963
+ return;
1964
+ }
1965
+ out(`\nDry run — nothing was changed. ${actionable.length} scope${actionable.length === 1 ? "" : "s"} ready${held.length ? `, ${held.length} held` : ""}${mixed.length ? `, ${mixed.length} blocked` : ""}${failed.length ? `, ${failed.length} failed` : ""}.`);
1966
+ if (actionable.length) out(`Apply with: oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
1967
+ if (held.length) out("Held scopes stay on their v1 locks and their legacy capabilities keep working — re-run when the catalog publishes their packages.");
1968
+ if (mixed.length) out("Blocked scopes stay on their v1 locks IN FULL and keep working — migration is all-or-nothing because a v2 lock has no place for an unconverted entry.");
1969
+ if (blocked.length) die(`${blocked.join("; ")} (${actionable.length} ready)`);
1970
+ return;
1971
+ }
1972
+
1973
+ // ---- apply, scope by scope (each independently transactional) ----
1974
+ const failures = [];
1975
+ // An un-migrated OAS scope is a failure, not an absence: apply must never
1976
+ // report overall success while one sits in the migrated universe.
1977
+ for (const f of oasScopes) {
1978
+ failures.push({ scope: f.dir, code: "oas-scope-unmigrated", message: `un-migrated OAS scope (${f.files.join(", ")}) — ${OAS_SCOPE_REMEDY}` });
1979
+ }
1980
+ for (const [i, p] of planned.entries()) {
1981
+ const row = planRows[i]; // planRows is built from planned, in order
1982
+ if (p.status === "failed") { row.status = "failed"; failures.push({ scope: p.scope, code: p.error.code, message: p.error.message }); continue; }
1983
+ if (p.status === "held") {
1984
+ row.status = "held";
1985
+ failures.push({ scope: p.scope, code: "official-mapping-unavailable", message: `held: ${p.held.map((s) => `${s.capabilityId} (${s.reason})`).join("; ")}` });
1986
+ out(`\nHELD ${shortPath(p.scope)} — left unchanged; its legacy capabilities keep working`);
1987
+ continue;
1988
+ }
1989
+ if (p.status === "nothing") {
1990
+ // No official work here, so nothing is applied and nothing is rewritten.
1991
+ // Say what the scope KEPT — `retained`, never `residue`: these entries
1992
+ // were not left beside a conversion, there simply was no conversion.
1993
+ row.status = "skipped";
1994
+ if (p.keep.length) row.retained = p.keep.map((k) => k.capabilityId).filter(Boolean);
1995
+ continue;
1996
+ }
1997
+ let r;
1998
+ try { r = applyLegacyLockMigration(p.scope, opts); }
1999
+ catch (e) {
2000
+ row.status = "failed";
2001
+ row.error = { code: e.code || "legacy-lock", message: String(e.message || e) };
2002
+ failures.push({ scope: p.scope, code: row.error.code, message: row.error.message });
2003
+ out(`\nFAILED ${shortPath(p.scope)} — ${row.error.message}`);
2004
+ continue;
2005
+ }
2006
+ row.status = r.skipped ? "skipped" : r.formatConverted ? "format-converted" : "migrated";
2007
+ row.migrated = r.migrated;
2008
+ // `retained` exists only for a SKIPPED scope left entirely on v1; a scope
2009
+ // that converts leaves nothing behind, and a mixed one is refused above.
2010
+ if (r.retained) row.retained = r.retained;
2011
+ row.warnings = r.warnings;
2012
+ for (const t of r.trust || []) result.trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(p.scope)}` });
2013
+ out(`\n ${shortPath(p.scope)}:`);
2014
+ for (const m of r.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
2015
+ for (const c of r.retained || []) out(` retained ${c} (this scope stays lockfileVersion 1, unchanged)`);
2016
+ for (const w of r.warnings) out(` WARNING: ${w}`);
2017
+ if (r.formatConverted) out(` format empty lockfileVersion 1 file → canonical v2`);
2018
+ else if (!r.skipped) out(` ${shortPath(r.file)} is now lockfileVersion 2 — config activation (from: installed) is unchanged`);
2019
+ }
2020
+
2021
+ // ---- exact next commands: trust first, then the requirement/install pass ----
2022
+ const migratedScopes = planRows.filter((r) => r.status === "migrated").map((r) => r.level);
2023
+ let requirements = [];
2024
+ try { requirements = migratedScopes.length ? aggregateMissingRequirements(migratedScopes) : []; }
2025
+ catch (e) { result.warnings.push(`host requirements not aggregated: ${e.message}`); }
2026
+ result.requirements = requirements.map((req) => ({
2027
+ command: req.command, requestedBy: req.requestedBy,
2028
+ consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
2029
+ ? `oats install --accept-requirement ${req.command} --dir ${shellQuote(dir)}` : null,
2030
+ }));
2031
+ result.nextCommands = [
2032
+ ...result.trust.map((t) => t.command),
2033
+ ...result.requirements.filter((q) => q.consentCommand).map((q) => q.consentCommand),
2034
+ `oats install --dir ${shellQuote(dir)}`,
2035
+ ];
2036
+
2037
+ if (JSON_MODE) {
2038
+ if (failures.length) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_MIGRATE_FAILED", message: `${failures.length} scope${failures.length > 1 ? "s" : ""} not migrated (${planRows.filter((r) => r.status === "migrated").length} migrated)`, details: result } })); process.exit(1); }
2039
+ jsonOk(result);
2040
+ return;
2041
+ }
2042
+ out("\nNext steps:");
2043
+ if (result.trust.length) {
2044
+ out(" 1. Review and approve the executable surfaces (approvals are never carried over):");
2045
+ for (const t of result.trust) out(` ${t.command}`);
2046
+ } else out(" 1. No executable surfaces to approve.");
2047
+ for (const q of result.requirements) {
2048
+ if (q.consentCommand) out(` * Missing host command ${q.command}: ${q.consentCommand}`);
2049
+ }
2050
+ out(` 2. Verify the runtime closure and host requirements (already-installed requirements are not reinstalled):`);
2051
+ out(` oats install --dir ${shellQuote(dir)}`);
2052
+ if (failures.length) {
2053
+ out("\nFailures by scope:");
2054
+ for (const f of failures) out(` ${shortPath(f.scope)}: ${f.message} [${f.code}]`);
2055
+ die(`${failures.length} scope${failures.length > 1 ? "s" : ""} not migrated (${planRows.filter((r) => r.status === "migrated").length} migrated)`);
2056
+ }
2057
+ }
2058
+
2059
+ /** `oats migrate --from-oas` — one transactional conversion per scope: rename
2060
+ * the OAS-named artifacts (breaks 1-3 of docs/migration-from-oas.md), then
2061
+ * chain the guided v1→v2 lock conversion under the SAME journal, so a failure
2062
+ * in either phase restores the original OAS bytes. Break 4 (stale v1
2063
+ * integrity) is resolved by re-acquisition, never by recomputing integrity. */
2064
+ function fromOasCmd({ dir, dryRun, recursive }) {
2065
+ const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2066
+ const warnings = [];
2067
+ const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
2068
+ let scopes;
2069
+ if (recursive) scopes = discoverOasScopes(dir, { teamScope }).map((f) => f.dir);
2070
+ else {
2071
+ const chain = detectOasScopes(dir);
2072
+ scopes = chain.filter((f) => f.dir === resolve(dir)).map((f) => f.dir);
2073
+ if (!scopes.length && chain.length) {
2074
+ cmdFail("E_BAD_ARGS", `no OAS scope files at ${resolve(dir)}, but an ancestor has them (${chain.map((f) => shortPath(f.dir)).join(", ")}) — run with --dir <that scope>, or --recursive to convert every visible OAS scope`);
2075
+ return;
2076
+ }
2077
+ }
2078
+ if (!scopes.length) {
2079
+ // Idempotency contract: a second run finds nothing and says so, exit 0.
2080
+ if (JSON_MODE) { jsonOk({ mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: [], trust: [], nextCommands: [], warnings }); return; }
2081
+ console.log("no oas-config.yaml / oas-lock.json found — nothing to migrate from OAS");
2082
+ return;
2083
+ }
2084
+
2085
+ const results = [];
2086
+ const trust = [];
2087
+ let failures = 0;
2088
+ for (const scope of scopes) {
2089
+ const plan = planFromOasScope(scope);
2090
+ const row = { scope, status: null, steps: plan.steps.map((s) => ({ kind: s.kind, from: s.from, to: s.to, note: s.note })), errors: plan.errors, plan: [], migrated: [], warnings: [] };
2091
+ results.push(row);
2092
+ if (plan.errors.length) { row.status = "failed"; failures++; continue; }
2093
+ if (dryRun) {
2094
+ // Phase-2 preview against a temp mirror of the lock alone — guided
2095
+ // planning reads nothing else, so the preview is exact and touch-free.
2096
+ const lockStep = plan.steps.find((s) => s.to.endsWith(OATS_LOCK_FILE));
2097
+ if (lockStep) {
2098
+ const mirror = mkdtempSync(join(tmpdir(), "oats-from-oas-plan-"));
2099
+ try {
2100
+ copyFileSync(lockStep.from, join(mirror, OATS_LOCK_FILE));
2101
+ const { plan: mplan, warnings: mw } = migrateLegacyLock(mirror, { official: true });
2102
+ row.plan = mplan.map(migratePlanRow);
2103
+ row.warnings = mw;
2104
+ row.status = mplan.some((s) => s.action === "hold" || s.action === "manual") ? "held" : "ready";
2105
+ if (row.status === "held") failures++;
2106
+ } catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; }
2107
+ finally { rmSync(mirror, { recursive: true, force: true }); }
2108
+ } else row.status = "ready";
2109
+ continue;
2110
+ }
2111
+ let journal;
2112
+ try { journal = applyFromOasScope(scope, plan); }
2113
+ catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; continue; }
2114
+ try {
2115
+ const r = applyLegacyLockMigration(scope, { official: true });
2116
+ journal.finalize();
2117
+ row.status = "migrated";
2118
+ row.migrated = r.migrated;
2119
+ // Phase 1 already rewrote the config's capability ids, so the guided
2120
+ // "update references in oats-config.yaml" warnings are satisfied here.
2121
+ row.warnings = r.warnings.filter((w) => !/update references in oats-config\.yaml/.test(w));
2122
+ for (const t of r.trust || []) trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(scope)}` });
2123
+ } catch (e) {
2124
+ journal.rollback();
2125
+ row.status = "failed";
2126
+ row.errors.push(`${String(e.message || e)} — scope restored to its original OAS state`);
2127
+ failures++;
2128
+ }
2129
+ }
2130
+
2131
+ out(`oats migrate --from-oas${recursive ? " --recursive" : ""}${dryRun ? " --dry-run" : ""} — ${scopes.length} OAS scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
2132
+ for (const w of warnings) out(`WARNING: ${w}`);
2133
+ for (const row of results) {
2134
+ out(`\n ${shortPath(row.scope)} [${row.status}]`);
2135
+ for (const s of row.steps) out(` ${s.kind === "rewrite" ? "rewrite " : "rename "} ${shortPath(s.from)} → ${shortPath(s.to)} (${s.note})`);
2136
+ for (const p of row.plan) out(` migrate ${p.capability} → package ${p.package}${p.migratesTo ? ` (as ${p.migratesTo})` : ""} [${p.action}]`);
2137
+ for (const m of row.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
2138
+ for (const e of row.errors) out(` ERROR ${e}`);
2139
+ for (const w of row.warnings) out(` WARNING: ${w}`);
2140
+ }
2141
+ const nextCommands = [...trust.map((t) => t.command), ...(results.some((r) => r.status === "migrated") ? [`oats install --dir ${shellQuote(dir)}`] : [])];
2142
+ const result = { mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: results, trust, nextCommands, warnings };
2143
+ if (JSON_MODE) {
2144
+ if (failures) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_FROM_OAS_FAILED", message: `${failures} scope${failures > 1 ? "s" : ""} not converted (${results.filter((r) => r.status === "migrated" || r.status === "ready").length} ${dryRun ? "ready" : "converted"})`, details: result } })); process.exit(1); }
2145
+ jsonOk(result);
2146
+ return;
2147
+ }
2148
+ if (!dryRun && nextCommands.length) {
2149
+ out("\nNext steps:");
2150
+ for (const c of nextCommands) out(` ${c}`);
2151
+ }
2152
+ if (dryRun) out(`\nDry run — nothing was changed. Apply with: oats migrate --from-oas${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
2153
+ if (failures) die(`${failures} scope${failures > 1 ? "s" : ""} not converted`);
2154
+ }
2155
+
2156
+ /** oats migrate — map this scope's v1 marketplace capability locks to package locks. */
2157
+ function migrateCmd() {
2158
+ const dir = dirFlag();
2159
+ const dryRun = args.includes("--dry-run");
2160
+ if (args.includes("--from-oas")) { fromOasCmd({ dir, dryRun, recursive: args.includes("--recursive") }); return; }
2161
+ if (args.includes("--official") || args.includes("--recursive")) {
2162
+ guidedMigrateCmd({ dir, dryRun, official: args.includes("--official"), recursive: args.includes("--recursive") });
2163
+ return;
2164
+ }
2165
+ if (dryRun) {
2166
+ let plan, warnings;
2167
+ try { ({ plan, warnings } = migrateLegacyLock(dir)); }
2168
+ catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
2169
+ if (!plan.length) {
2170
+ // "Nothing to migrate" on an OAS scope is a false success: the scope is
2171
+ // not empty, it is pre-rename, and this kernel cannot read it (aweb-abfy.1).
2172
+ const oas = detectOasScopes(dir);
2173
+ if (oas.length) { cmdFail("oas-scope-unmigrated", `nothing this command can migrate here, but un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}`); return; }
2174
+ }
2175
+ if (JSON_MODE) { jsonOk({ dryRun: true, plan, warnings }); return; }
2176
+ if (!plan.length) { console.log("Nothing to migrate at this scope."); return; }
2177
+ for (const s of plan) console.log(s.action === "convert-format" ? `${s.action.padEnd(14)} ${s.note}` : `${s.action.padEnd(10)} ${s.capabilityId}${s.package ? ` → ${s.package.spec}` : ""}`);
2178
+ for (const w of warnings) console.log(`WARNING: ${w}`);
2179
+ return;
2180
+ }
2181
+ let r;
2182
+ try { r = applyLegacyLockMigration(dir); }
2183
+ catch (e) {
2184
+ const oas = detectOasScopes(dir);
2185
+ const oasNote = oas.length ? ` NOTE: un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}` : "";
2186
+ cmdFail(e.code || "legacy-lock", `${e.message || e}${oasNote}`); return;
2187
+ }
2188
+ if (JSON_MODE) { jsonOk(r); return; }
2189
+ for (const m of r.migrated) console.log(`migrated ${m.capability} → package ${m.package}@${m.version}`);
2190
+ for (const w of r.warnings) console.log(`WARNING: ${w}`);
2191
+ if (r.formatConverted) { console.log(`${shortPath(r.file)} was an empty lockfileVersion 1 file — converted to canonical v2.`); return; }
2192
+ if (r.file) console.log(`${shortPath(r.file)} is now lockfileVersion 2. Config activation (from: installed) is unchanged; re-run \`oats trust\` for executable capabilities — package integrity approvals are not carried over.`);
2193
+ }
2194
+
2195
+ /** oats update <package> — transactional package update with diff + trust reset. */
2196
+ function updatePackageCmd(id) {
2197
+ const dir = dirFlag();
2198
+ let r;
2199
+ try { r = updatePackage(dir, id); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
2200
+ if (JSON_MODE) { jsonOk(r); return; }
2201
+ // A moved package root is reported even when the bytes are identical: the
2202
+ // lock now points somewhere else in the repository, and that is exactly the
2203
+ // change an operator must see (contract §7).
2204
+ const pathLine = () => console.log(` package path ${r.before.path} → ${r.after.path} (the selected package root MOVED in the source)`);
2205
+ if (!r.changed) {
2206
+ console.log(`${r.package} is already up to date (${r.after.version}, ${r.after.integrity}).`);
2207
+ if (r.pathChanged) pathLine();
2208
+ return;
2209
+ }
2210
+ console.log(`Updated ${r.package}: ${r.before.version} (${r.before.commit}) → ${r.after.version} (${r.after.commit})`);
2211
+ console.log(` integrity ${r.before.integrity} → ${r.after.integrity}`);
2212
+ if (r.pathChanged) pathLine();
2213
+ if (r.addedCapabilities.length) console.log(` + capabilities: ${r.addedCapabilities.join(", ")}`);
2214
+ if (r.removedCapabilities.length) console.log(` - capabilities: ${r.removedCapabilities.join(", ")}`);
2215
+ for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
2216
+ if (r.invalidatedApprovals.length) console.log(` APPROVALS INVALIDATED (integrity changed): ${r.invalidatedApprovals.join(", ")} — re-approve with \`oats trust\` after review.`);
2217
+ }
2218
+
2219
+ // ---------- init ----------
2220
+ /**
2221
+ * oats init [--raw] [--dir <dir>] [--knowledge <id>] [--messaging <id>] [--tasks <id>]
2222
+ *
2223
+ * Per-layer flags name a canonical capability ID or "none". A layer is filled by
2224
+ * a capability already at this scope (own store first — no config exists yet, so
2225
+ * the config-chain walk cannot see it), otherwise by acquiring the official
2226
+ * PACKAGE that supplies it through the materialization engine. Acquisition is
2227
+ * not activation, not executable trust and not requirement consent, and the
2228
+ * whole run is one transaction that rolls back on any failure.
2229
+ */
2230
+ /** Resolve a template (name via outer-config `templates:` maps, local path, or git URL's
2231
+ * main-branch oats-config.yaml) into snapshot text with a provenance comment. */
2232
+ function loadTemplateConfig(spec, dir) {
2233
+ // THROWS typed errors rather than exiting: `oats init --template` reports
2234
+ // through the same single JSON envelope as every other init form.
2235
+ const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
2236
+ let source = spec;
2237
+ const isDirect = /^(https?:\/\/|git@|ssh:\/\/)/.test(spec) || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~");
2238
+ if (!isDirect) {
2239
+ let named;
2240
+ for (const cfg of configChain(dir)) {
2241
+ if (cfg.templates?.[spec]) { named = { value: cfg.templates[spec], level: cfg._level }; break; }
2242
+ }
2243
+ if (!named) fail("E_UNKNOWN_TEMPLATE", `unknown template "${spec}" — declare it under templates: in an outer oats-config.yaml, or pass a path/git URL`);
2244
+ source = /^(https?:\/\/|git@|ssh:\/\/)/.test(named.value) || named.value.startsWith("/") || named.value.startsWith("~")
2245
+ ? named.value : resolve(named.level, named.value);
2246
+ }
2247
+ let body, provenance;
2248
+ if (/^(https?:\/\/|git@|ssh:\/\/)/.test(source)) {
2249
+ const tmp = mkdtempSync(join(tmpdir(), "oats-template-"));
2250
+ try {
2251
+ execFileSync("git", ["clone", "-q", "--depth", "1", source, tmp], { stdio: "inherit" });
2252
+ const cfgFile = join(tmp, "oats-config.yaml");
2253
+ if (!existsSync(cfgFile)) fail("E_TEMPLATE_SOURCE", `template repo has no oats-config.yaml on its default branch: ${source}`);
2254
+ body = readFileSync(cfgFile, "utf8");
2255
+ const commit = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
2256
+ provenance = `${source}@${commit.slice(0, 12)}`;
2257
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
2258
+ } else {
2259
+ // Replacer FUNCTION, not a replacement string: `$&`, `$'`, `` $` `` and
2260
+ // `$1` are substitution syntax in String.replace, and a home directory may
2261
+ // legally contain them.
2262
+ const path = resolve(source.replace(/^~\//, () => `${homedir()}/`));
2263
+ if (!existsSync(path)) fail("E_TEMPLATE_SOURCE", `template config not found: ${path}`);
2264
+ body = readFileSync(path, "utf8");
2265
+ provenance = path;
2266
+ }
2267
+ // Snapshot: strip template-registry keys that make no sense in the seeded config.
2268
+ const lines = body.replace(/\n*$/, "\n").split("\n");
2269
+ const scaffoldName = scaffoldConfigName(dir);
2270
+ const out = []; let skipping = false;
2271
+ for (const line of lines) {
2272
+ if (/^templates:\s*$/.test(line)) { skipping = true; continue; }
2273
+ if (skipping) { if (/^\S/.test(line) && line.trim()) skipping = false; else continue; }
2274
+ // Replacer FUNCTION, not a replacement string — this is a WRITE, so a
2275
+ // directory named `x$&y` would otherwise persist a corrupted `name:` line
2276
+ // (`name: xname: template-namey`).
2277
+ out.push(line.replace(/^name:.*$/, () => `name: ${scaffoldName}`));
2278
+ }
2279
+ return `# template: ${provenance} (snapshot — later template edits do not propagate)\n${out.join("\n").replace(/\n*$/, "\n")}`;
2280
+ }
2281
+
2282
+ function init() {
2283
+ const raw = args.includes("--raw");
2284
+ const dir = dirFlag();
2285
+ const file = join(dir, "oats-config.yaml");
2286
+ const pkgSrc = flag("package");
2287
+ // Every init form — classic, --template and --package — reports through the
2288
+ // SAME one-envelope JSON boundary; nothing here may print two documents.
2289
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2290
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2291
+ if (existsSync(file)) bail("E_CONFIG_EXISTS", `${shortPath(file)} already exists — edit it or use \`oats use\``);
2292
+ // The scaffolded `name:` value is FILESYSTEM input. Refuse it up front, before
2293
+ // any init form mutates anything — a basename that cannot stay one YAML scalar
2294
+ // must abort the run, not be discovered halfway through a transaction.
2295
+ try { scaffoldConfigName(dir); }
2296
+ catch (e) { bail(e.code, e.message); return; }
2297
+
2298
+ if (pkgSrc && pkgSrc !== true) { initPackage(pkgSrc, dir, file); return; }
2299
+ if (pkgSrc === true) { bail("E_USAGE", "--package needs a package id, local path, or git URL"); return; }
2300
+
2301
+ const template = flag("template");
2302
+ if (template && template !== true) {
2303
+ let text;
2304
+ try { text = loadTemplateConfig(template, dir); }
2305
+ catch (e) { bail(e.code || "E_TEMPLATE_SOURCE", e.message); return; }
2306
+ // Seeding is a transaction too. A template can carry keys this kernel
2307
+ // refuses, or lock entries that will not restore; either way the config this
2308
+ // run wrote must not be left behind for the next command to trip over, and
2309
+ // the failure must be a typed error rather than an uncaught stack.
2310
+ let journal;
2311
+ try { journal = beginRunJournal(dir); }
2312
+ catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
2313
+ let activated = [];
2314
+ try {
2315
+ writeFileSync(file, text);
2316
+ note(`Created ${shortPath(file)} (${levelOf(dir)} level) from template ${template}`);
2317
+ // The GATE is that the kernel can read this config: a template carrying a
2318
+ // retired key or a broken shape is a broken template, and leaving it
2319
+ // behind would break every later command at this scope.
2320
+ configChain(dir);
2321
+ restore(dir);
2322
+ // Activation is NOT a gate. A template's whole point is to seed policy you
2323
+ // then acquire — a capability it activates but nothing supplies yet is the
2324
+ // expected state right after seeding, not a reason to refuse the config.
2325
+ try { activated = resolveOatsConfig(dir).capabilities.map((c) => ({ capability: c.id, layer: c.layer || null })); }
2326
+ catch (e) { note(`NOTE: ${shortPath(file)} does not resolve yet — ${e.message}. Acquire what it activates (\`oats install <source>\`), then re-check with \`oats doctor\`.`); }
2327
+ journal.finalize();
2328
+ } catch (e) {
2329
+ const report = journal.rollback();
2330
+ const detail = `${shortPath(file)} could not be seeded from template ${template}: ${e.message}`;
2331
+ bail(e.code || "E_TEMPLATE_UNUSABLE", report.complete ? detail : `${detail} — ${report.summary}`);
2332
+ return;
2333
+ }
2334
+ if (JSON_MODE) { jsonOk({ file, level: levelOf(dir), raw, adopted: false, template, acquired: [], activated, requirements: [] }); return; }
2335
+ offerTmuxMouseScrolling();
2336
+ return;
2337
+ }
2338
+ if (template === true) { bail("E_USAGE", "--template needs a name, local config path, or git URL"); return; }
2339
+
2340
+ // Per-layer overrides: --knowledge oats.okf, --messaging none, --tasks oats.jira …
2341
+ const overrides = {};
2342
+ const market = marketplaceCapabilities();
2343
+ // Own-scope manifests are read DIRECTLY: no oats-config.yaml exists here yet,
2344
+ // so the config-chain walk cannot see this scope's own store, and a
2345
+ // capability already installed here would look unknown.
2346
+ // Object.assign onto a null prototype, never an object spread: `{ ...map }`
2347
+ // re-plainifies the null-prototype sources, and `mans[v]` is then indexed with
2348
+ // a `--<layer>` value the operator typed — `--knowledge constructor` would
2349
+ // read Object.prototype.constructor as a manifest and report a layer mismatch
2350
+ // for a capability that does not exist.
2351
+ const mans = Object.assign(Object.create(null), market, capabilityManifests(dir), ownScopeCapabilityManifests(dir));
2352
+ for (const layer of LAYERS) {
2353
+ const v = flag(layer);
2354
+ if (v === undefined) continue;
2355
+ if (v === true || String(v).startsWith("--")) bail("E_USAGE", `--${layer} needs a canonical capability ID or "none"`);
2356
+ if (v !== "none") {
2357
+ // Known locally: its declared layer is checkable right now, before any
2358
+ // mutation. Otherwise the official catalog may still supply it, and the
2359
+ // layer is verified against the MATERIALIZED manifest after acquisition —
2360
+ // inside the run transaction, so a disagreement rolls the whole run back.
2361
+ if (mans[v]) {
2362
+ if (mans[v].layer !== layer) bail("E_LAYER_MISMATCH", `capability "${v}" declares layer "${mans[v].layer || "none"}", not "${layer}"`);
2363
+ } else if (!officialCapabilityPackage(v).available) {
2364
+ bail("E_UNKNOWN_CAPABILITY", `unknown capability "${v}" for --${layer} — it is not acquired at ${shortPath(dir)}, not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it (catalog: ${Object.keys(officialPackageCatalog()).join(", ") || "empty"})`);
2365
+ }
2366
+ }
2367
+ overrides[layer] = v;
2368
+ }
2369
+
2370
+ const defaults = raw
2371
+ ? { knowledge: "none", messaging: "none", tasks: "none" }
2372
+ : { knowledge: "oats.okf", messaging: "oats.aweb", tasks: undefined };
2373
+ let layers = { ...defaults, ...overrides };
2374
+
2375
+ // Interactive TTY with no explicit layer flags: present each default and ask.
2376
+ // Non-interactive contexts (agents, CI) keep flags-or-silent-defaults — never hang.
2377
+ if (!raw && !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY && !Object.keys(overrides).length) {
2378
+ const byLayer = (l) => Object.values(mans).filter((m) => m.layer === l).map((m) => m.capability);
2379
+ console.log("Fundamental layers for this scope — Enter keeps the default, or type a capability id / \"none\":");
2380
+ const ask = (prompt) => {
2381
+ process.stdout.write(prompt);
2382
+ const buffer = Buffer.alloc(256);
2383
+ let length = 0;
2384
+ try { length = readSync(process.stdin.fd, buffer, 0, buffer.length); } catch { /* EOF */ }
2385
+ return buffer.subarray(0, length).toString("utf8").trim();
2386
+ };
2387
+ for (const layer of LAYERS) {
2388
+ const options = byLayer(layer);
2389
+ const def = layers[layer] || "none";
2390
+ while (true) {
2391
+ const answer = ask(` ${layer.padEnd(10)} [${def}] (options: ${[...options, "none"].join(", ")}): `);
2392
+ if (!answer) break;
2393
+ if (answer === "none" || options.includes(answer)) { layers[layer] = answer; break; }
2394
+ console.log(` unknown "${answer}" — pick one of: ${[...options, "none"].join(", ")}`);
2395
+ }
2396
+ }
2397
+ if ((layers.messaging || "none") !== "none") console.log(" (messaging via aweb: after init, run `oats aweb setup` for guided onboarding)");
2398
+ }
2399
+ // ---- Everything below MUTATES. It is ONE run-level transaction: the config
2400
+ // file, the lock, the flat capability artifacts, the capability .gitignore
2401
+ // and any `.agents` anchor this run creates roll back together. A capability
2402
+ // that was already installed at this scope before the run is restored
2403
+ // byte-identically — this run only ever undoes its own changes. ----
2404
+ let journal;
2405
+ try { journal = beginRunJournal(dir); }
2406
+ catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
2407
+ const abort = (e, code) => {
2408
+ const report = journal.rollback();
2409
+ bail(code || e.code || "E_INIT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
2410
+ };
2411
+
2412
+ const acquisitions = [];
2413
+ let resolved;
2414
+ const lines = [
2415
+ `name: ${scaffoldConfigName(dir)}`,
2416
+ "",
2417
+ "# ── Agent types (families) — declared here by name (or via `oats type add`);",
2418
+ "# each soul opts in via `type: <name>` in its soul.yaml. Capability entries can target them.",
2419
+ "# agent-types:",
2420
+ "# reviewers:",
2421
+ "# description: Agents that review changes",
2422
+ "",
2423
+ "capabilities:",
2424
+ " # Fundamental layers — exclusive slots; a capability entry or an explicit none.",
2425
+ " layers:",
2426
+ ];
2427
+ try {
2428
+ for (const layer of LAYERS) {
2429
+ const selected = layers[layer];
2430
+ if (!selected) { lines.push(` # ${layer}: (unset — inherits from outer config scopes; set an entry or "none")`); continue; }
2431
+ if (selected === "none") { lines.push(` ${layer}: none`); continue; }
2432
+ // Already here (own scope first — see above), or acquired now.
2433
+ const manifest = ownScopeCapabilityManifest(dir, selected)
2434
+ || capabilityManifest(selected, dir)
2435
+ || acquireLayerCapability(dir, selected, layer, acquisitions, note);
2436
+ lines.push(` ${layer}:`);
2437
+ lines.push(` capability: ${manifest.capability}`);
2438
+ if (String(manifest._origin).startsWith("installed:")) { lines.push(" from: installed"); lines.push(` # injection-override: .agents/injections/capabilities/${manifest.capability}.md`); }
2439
+ else if (String(manifest._origin).startsWith("owned:")) { lines.push(" from: owned"); lines.push(` # injection edited at source: .agents/capabilities/owned/${manifest.capability}/injects/`); }
2440
+ }
2441
+ lines.push(
2442
+ " # Additive capabilities — non-exclusive; target global, agent-types, or souls.",
2443
+ " # additive:",
2444
+ " # <capability-id>:",
2445
+ " # from: installed",
2446
+ " # global: true",
2447
+ " # # injection-override: .agents/injections/capabilities/<capability-id>.md",
2448
+ "",
2449
+ "# ── Work modes — optional per-mode env bootstrap.",
2450
+ "# `setup:` runs inside each NEW worktree right after `git worktree add` — use it",
2451
+ "# for env setup scripts (installs, .env copying, direnv, mise, etc.).",
2452
+ "# The path is relative to this config's directory.",
2453
+ "work-modes:",
2454
+ " worktree:",
2455
+ " # setup: scripts/setup-worktree.sh",
2456
+ "",
2457
+ "# ── OATS defaults — the framework's baseline instruction block.",
2458
+ "oats:",
2459
+ " # injection-override: .agents/injections/oats-defaults/oats.md",
2460
+ );
2461
+ writeFileSync(file, lines.join("\n") + "\n");
2462
+ // Resolve INSIDE the transaction: a config this run wrote that cannot
2463
+ // resolve is a broken scope, so it fails the init and rolls back rather
2464
+ // than being left behind for the next command to trip over.
2465
+ resolved = resolveOatsConfig(dir);
2466
+ journal.finalize();
2467
+ } catch (e) { abort(e); return; }
2468
+
2469
+ note(`Created ${shortPath(file)} (${levelOf(dir)} level${raw ? ", raw" : ""})`);
2470
+ // Acquisition is not activation, not executable trust, and not requirement
2471
+ // consent — say so per acquisition rather than implying the layer is ready.
2472
+ for (const a of acquisitions) {
2473
+ if (!a.executableSurface.length) continue;
2474
+ note(`Executable surfaces from ${a.package || "the marketplace"} are blocked until trusted: ${a.executableSurface.map((c) => `oats trust ${c}`).join("; ")}`);
2475
+ }
2476
+
2477
+ const r = resolved;
2478
+ const activated = [];
2479
+ for (const cap of r.capabilities) {
2480
+ activated.push({ capability: cap.id, layer: cap.layer || null });
2481
+ note(`Activated: ${cap.id}${cap.layer ? ` → ${cap.layer}` : ""}`);
2482
+ for (const miss of cap.missingRequires) note(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
2483
+ }
2484
+ if (JSON_MODE) {
2485
+ jsonOk({
2486
+ file, level: levelOf(dir), raw, adopted: false,
2487
+ layers: Object.fromEntries(LAYERS.map((l) => [l, layers[l] ?? null])),
2488
+ acquired: acquisitions, activated,
2489
+ // Same facts the human run prints, in the same run: who asked, why, and
2490
+ // the ONE copyable command that consents to installing it. Init never
2491
+ // runs it — reporting a requirement and acting on it are separate steps,
2492
+ // and an agent reading this envelope must be able to tell them apart.
2493
+ requirements: r.capabilities.flatMap((c) => c.missingRequires.map((m) => ({
2494
+ capability: c.id, command: m.command, why: m.why || null, install: m.install || null,
2495
+ consentCommand: `oats install --accept-requirement ${m.command} --dir ${shellQuote(dir)}`,
2496
+ }))),
2497
+ });
2498
+ return;
2499
+ }
2500
+ offerTmuxMouseScrolling();
2501
+ }
2502
+
2503
+ /** Acquire the capability backing one fundamental layer at classic-init time.
2504
+ *
2505
+ * Catalog-first: when an official package supplies the capability it comes
2506
+ * through the package engine — flat materialization, a capability-materialization
2507
+ * lock, and NO implicit executable trust. The legacy standalone-capability route
2508
+ * survives only for marketplace capabilities the official catalog cannot supply
2509
+ * today, and it is the only branch that still writes a v1 lock.
2510
+ *
2511
+ * Throws on every failure: the caller holds the run journal, and exiting here
2512
+ * would strand its backup. */
2513
+ function acquireLayerCapability(dir, capId, layer, acquired, note) {
2514
+ const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
2515
+ const official = officialCapabilityPackage(capId);
2516
+ if (official.available) {
2517
+ const acq = acquirePackage(dir, official.package);
2518
+ if (!acq.capabilities.some((c) => c.capability === capId)) {
2519
+ fail("E_LAYER_NOT_EXPORTED", `package ${official.package} does not export capability "${capId}" — it exports ${acq.capabilities.map((c) => c.capability).join(", ") || "nothing"}`);
2520
+ }
2521
+ // The layer is verified against the manifest actually WRITTEN TO DISK, never
2522
+ // against the marketplace copy or the catalog's word for it.
2523
+ const manifest = ownScopeCapabilityManifest(dir, capId);
2524
+ if (!manifest) fail("E_LAYER_UNREADABLE", `capability "${capId}" was materialized but its manifest under ${shortPath(installedCapabilityDir(dir, capId))} is unreadable`);
2525
+ if (manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
2526
+ const executableSurface = acq.capabilities
2527
+ .filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
2528
+ .map((c) => c.capability);
2529
+ acquired.push({
2530
+ layer, capability: capId, route: "package", package: official.package, via: official.via,
2531
+ packages: acq.installed.map((p) => ({ package: p.package, version: p.version || null, commit: p.commit || null })),
2532
+ lockFile: acq.lockFile, trusted: false, executableSurface,
2533
+ });
2534
+ note(`Acquired package ${official.package} for the ${layer} layer → ${capId} (${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")}) → ${shortPath(acq.lockFile)}`);
2535
+ return { ...manifest, _origin: `installed:${dir}` };
2536
+ }
2537
+ const market = marketplaceCapabilities();
2538
+ if (!market[capId]) {
2539
+ fail("E_UNKNOWN_CAPABILITY", `capability "${capId}" is not acquired at ${shortPath(dir)}, is not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it`);
2540
+ }
2541
+ // Legacy route: kernel-bundled marketplace capabilities predate the official
2542
+ // packages, ship with the kernel already installed, and keep their v1 lock and
2543
+ // acquisition-time trust until the catalog covers them.
2544
+ const r = acquireCapability(dir, capId);
2545
+ try {
2546
+ writeCapabilityLock(dir, r.manifest.capability, {
2547
+ source: r.source, version: r.manifest.version || null, integrity: r.integrity, trustedExecutables: true,
2548
+ });
2549
+ } catch (e) { rmSync(r.dest, { recursive: true, force: true }); throw e; }
2550
+ if (r.manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${r.manifest.layer || "none"}", not "${layer}"`);
2551
+ acquired.push({ layer, capability: capId, route: "marketplace", package: null, via: "marketplace", packages: [], lockFile: join(dir, OATS_LOCK_FILE), trusted: true, executableSurface: [] });
2552
+ note(`Acquired ${r.manifest.capability}@${r.manifest.version} from the marketplace → ${shortPath(r.dest)}`);
2553
+ return { ...r.manifest, _origin: `installed:${dir}` };
2554
+ }
2555
+
2556
+ /** Capability manifests physically present at THIS scope's own store.
2557
+ *
2558
+ * `capabilityManifests` walks the config chain, so during `oats init` — when no
2559
+ * oats-config.yaml exists at the target scope yet — this scope is not a level and
2560
+ * its own installed/ and owned/ capabilities are invisible. Init reads them
2561
+ * directly instead, which is also what makes a same-run acquisition visible to
2562
+ * the rest of the run. */
2563
+ function ownScopeCapabilityManifests(dir) {
2564
+ // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
2565
+ // braces on the write side (store directory names are identity-validated at
2566
+ // acquisition); it matters on the read side, where `oats init` indexes this
2567
+ // map with a `--<layer>` flag value the operator typed.
2568
+ const out = Object.create(null);
2569
+ for (const [sub, origin] of [[installedCapabilitiesDir(dir), "installed"], [ownedCapabilitiesDir(dir), "owned"]]) {
2570
+ if (!existsSync(sub)) continue;
2571
+ let entries;
2572
+ try { entries = readdirSync(sub, { withFileTypes: true }); } catch { continue; }
2573
+ for (const e of entries) {
2574
+ // Dot-prefixed entries are transaction staging, never installed content.
2575
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
2576
+ let raw;
2577
+ try { raw = JSON.parse(readFileSync(join(sub, e.name, "oats.json"), "utf8")); } catch { continue; }
2578
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
2579
+ // Strip BEFORE the spread. `_dir` and `_origin` are reassigned just after
2580
+ // it, but every OTHER annotation in the namespace — `_capabilityLock`,
2581
+ // `_package`, `_soulDir` … — would flow straight out of an
2582
+ // artifact-controlled document. That is the exact shape the kernel's own
2583
+ // manifest reader was fixed for, and this map is merged OVER that
2584
+ // stripped one, so leaving it raw kept the forgery carrier alive.
2585
+ const m = stripInternalAnnotations(raw);
2586
+ if (typeof m.capability === "string") out[m.capability] = { ...m, _dir: join(sub, e.name), _origin: `${origin}:${dir}` };
2587
+ }
2588
+ }
2589
+ return out;
2590
+ }
2591
+ const ownScopeCapabilityManifest = (dir, capId) => ownScopeCapabilityManifests(dir)[capId];
2592
+
2593
+ // ---------- roster: status / spawn / retire / create ----------
2594
+ function status() {
2595
+ if (args.includes("--team")) return statusTeam();
2596
+ const root = ensureRoot(dirFlag());
2597
+ const data = listInstances(root);
2598
+ if (args.includes("--json")) { console.log(JSON.stringify({ root, agents: data }, null, 2)); return; }
2599
+ console.log(`oats status — agents root ${shortPath(root)}\n`);
2600
+ if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
2601
+ for (const a of data) {
2602
+ console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
2603
+ if (a.description) console.log(` ${a.description}`);
2604
+ for (const i of a.instances) {
2605
+ console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2606
+ }
2607
+ }
2608
+ const defs = listAgentDefs(process.cwd());
2609
+ if (defs.length) console.log(`\n importable defs: ${defs.map((d) => d.name).join(", ")}`);
2610
+ }
2611
+
2612
+ function statusTeam() {
2613
+ const ctx = dirFlag();
2614
+ const r = resolveOatsConfig(ctx);
2615
+ if (!r.team) die(`no team declared in the config chain from ${shortPath(ctx)} — add a "team:" block (name, optional id) at the deployment scope`);
2616
+ const roots = teamAgentRoots(r.team.scope);
2617
+ const payload = { team: r.team, roots: [] };
2618
+ for (const root of roots) payload.roots.push({ root, agents: listInstances(root) });
2619
+ if (args.includes("--json")) { console.log(JSON.stringify(payload, null, 2)); return; }
2620
+ console.log(`oats status — team ${r.team.name}${r.team.id ? ` (${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]\n`);
2621
+ if (!roots.length) { console.log(" (no agents/ directories in the team scope)"); return; }
2622
+ for (const { root, agents } of payload.roots) {
2623
+ console.log(` ${shortPath(root)}`);
2624
+ if (!agents.length) { console.log(" (no agents)"); continue; }
2625
+ for (const a of agents) {
2626
+ console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""}${a.description ? ` — ${a.description}` : ""}`);
2627
+ for (const i of a.instances) console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"}`);
2628
+ }
2629
+ }
2630
+ }
2631
+
2632
+ function spawnCmd() {
2633
+ // JSON mode: contract envelope, stable error codes, stderr-only progress.
2634
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2635
+ const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2636
+ const name = args[1];
2637
+ if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
2638
+ // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
2639
+ // ANY side effect — including root discovery and local-agent upsert (an
2640
+ // --instructions-file spawn must not scaffold/overwrite a local soul before
2641
+ // this rejection; reviewer-b671de0).
2642
+ if (args.includes("--instance")) bail("E_BAD_ARGS", "--instance was removed by the runtime-boundary ruling — use --purpose <slug> (deterministic <agent>-<purpose> naming)");
2643
+ if (args.includes("--ephemeral")) bail("E_BAD_ARGS", "--ephemeral was removed by the runtime-boundary ruling — declare the agent in a capability manifest (agents:) for automatic ephemeral semantics");
2644
+ let root;
2645
+ try { root = ensureRoot(dirFlag()); }
2646
+ catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
2647
+ let agent = findAgent(root, name);
2648
+ const instrFile = flag("instructions-file");
2649
+ const defFile = flag("def-file");
2650
+ if (!agent && !instrFile && !defFile) {
2651
+ // Capability-defined agent: a package's `agents:` soul, active in this context.
2652
+ const capAgent = findCapabilityAgent(dirFlag(), root, name);
2653
+ if (capAgent) {
2654
+ agent = capAgent;
2655
+ note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
2656
+ }
2657
+ }
2658
+ if (!agent && !instrFile && !defFile) {
2659
+ // Cross-repo lookup: the soul may live in a sibling repo of the team scope.
2660
+ // Unique match wins; the instance homes with its owning repo's agents root.
2661
+ const teamHit = findTeamAgent(dirFlag(), name);
2662
+ const remote = (teamHit?.matches || []).filter((m) => resolve(m.root) !== resolve(root));
2663
+ if (remote.length > 1) bail("E_AMBIGUOUS_SOUL", `soul "${name}" found in multiple team repos: ${remote.map((m) => shortPath(m.root)).join(", ")} — re-run with --dir <that repo>`);
2664
+ if (remote.length === 1) {
2665
+ root = remote[0].root;
2666
+ agent = remote[0].agent;
2667
+ note(`(cross-repo: soul "${name}" found at ${shortPath(root)} — instance homes there)`);
2668
+ }
2669
+ }
2670
+ // local agents: create/update from raw instructions or a single-file def
2671
+ if (instrFile || defFile || !agent) {
2672
+ if (!agent && !instrFile && !defFile) {
2673
+ const def = listAgentDefs(process.cwd()).find((d) => d.name === name);
2674
+ if (!def) bail("E_UNKNOWN_AGENT", `unknown agent "${name}" (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"}; importable defs: ${listAgentDefs(process.cwd()).map((d) => d.name).join(", ") || "none"}) — pass --instructions-file or --def-file to create a local agent`);
2675
+ agent = upsertLocalAgent(root, { name: def.name, file: def.path, repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model") });
2676
+ } else if (!agent || agent.kind === "local") {
2677
+ agent = upsertLocalAgent(root, {
2678
+ name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
2679
+ repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"),
2680
+ });
2681
+ } else {
2682
+ bail("E_BAD_ARGS", `"${name}" is a persistent agent — spawn it without --instructions-file/--def-file`);
2683
+ }
2684
+ }
2685
+ // Lineage is explicit: --relation child|sibling|parent|unrelated anchors the new
2686
+ // instance to --relative-to <instance>. --parent X is sugar for
2687
+ // --relative-to X --relation child (agents spawning sub-agents pass their own
2688
+ // name, e.g. --parent "$OATS_INSTANCE"). Without a relation, the spawn is
2689
+ // operator-origin and lands top-level — ambient env vars in the shell are
2690
+ // never treated as parentage.
2691
+ const parent = flag("parent");
2692
+ if (parent !== undefined && (parent === true || !String(parent).trim())) bail("E_BAD_ARGS", "--parent needs an instance name");
2693
+ let relation = flag("relation");
2694
+ if (relation !== undefined && (relation === true || !String(relation).trim())) bail("E_BAD_ARGS", "--relation needs a value: child|sibling|parent|unrelated");
2695
+ if (relation && !RELATIONS.includes(relation)) bail("E_BAD_ARGS", `unknown --relation "${relation}" (child|sibling|parent|unrelated)`);
2696
+ let relativeTo = flag("relative-to");
2697
+ if (relativeTo !== undefined && (relativeTo === true || !String(relativeTo).trim())) bail("E_BAD_ARGS", "--relative-to needs an instance name");
2698
+ if (relation && relation !== "unrelated" && !relativeTo) bail("E_BAD_ARGS", `--relation ${relation} requires --relative-to <instance>`);
2699
+ if (relativeTo && !relation) bail("E_BAD_ARGS", "--relative-to requires --relation child|sibling|parent");
2700
+ if (relation === "unrelated" && relativeTo) bail("E_BAD_ARGS", "--relation unrelated takes no --relative-to");
2701
+ if (parent && (relation || relativeTo)) bail("E_BAD_ARGS", "--parent is sugar for --relative-to <instance> --relation child — use one form, not both");
2702
+ if (parent) { relation = "child"; relativeTo = parent; }
2703
+ // Attached agents are ALWAYS children (design decision): the only relation
2704
+ // flags allowed are the child form — required when the workDir is not an
2705
+ // instance's own <home>/work (integration worktrees). The kernel verifies
2706
+ // ownership canonically (including soul-default attached mode).
2707
+ if ((flag("work") === "attached") && relation && relation !== "child") bail("E_BAD_ARGS", "attached agents are always children of the work-tree owner — only --parent <instance> (or --relation child) is valid with --work attached");
2708
+ // NOTE: explicit "unrelated" is passed through to the kernel.
2709
+ if (relativeTo && relation !== "unrelated") {
2710
+ // findInstanceHome also sees capability-defined agents' instance homes
2711
+ // (local-agents/<name>/ without a local soul) — e.g. a reviewer passing
2712
+ // --parent "$OATS_INSTANCE" from a capability agent.
2713
+ if (!findInstanceHome(root, relativeTo) && !findTeamInstance(dirFlag(), relativeTo)) bail(parent ? "E_PARENT_NOT_FOUND" : "E_RELATIVE_NOT_FOUND", `${parent ? "--parent" : "--relative-to"} "${relativeTo}" does not match any known instance`);
2714
+ }
2715
+ const taskText = flag("task");
2716
+ if (taskText === true) bail("E_BAD_ARGS", "--task needs a value (use --task-file for long tasks)");
2717
+ const taskFileFlag = flag("task-file");
2718
+ if (taskFileFlag === true) bail("E_BAD_ARGS", "--task-file needs a path");
2719
+ if (taskFileFlag && !existsSync(taskFileFlag)) bail("E_BAD_ARGS", `--task-file not found: ${taskFileFlag}`);
2720
+ const relativeRoot = flag("relative-root");
2721
+ if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
2722
+ if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
2723
+ let r;
2724
+ try {
2725
+ r = spawnInstance(root, agent, {
2726
+ purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
2727
+ repo: flag("repo") || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2728
+ work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), model: flag("model"), branch: flag("branch"),
2729
+ launch: !args.includes("--no-launch"),
2730
+ });
2731
+ } catch (e) {
2732
+ // A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
2733
+ // (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
2734
+ // consumer the spawn mechanism broke, when the fixable fact is a poisoned
2735
+ // document the message already names. The shared boundary renders it.
2736
+ if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
2737
+ bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
2738
+ }
2739
+ if (JSON_MODE) {
2740
+ // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
2741
+ jsonOk({
2742
+ instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2743
+ branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2744
+ tmux: r.tmux || null, repo: r.repo || null, runtime: r.runtime || null,
2745
+ model: r.model || null, parent: r.parentInstance || null,
2746
+ sibling: r.siblingInstance || null, relation: r.relation || null,
2747
+ spawnOrigin: r.spawnOrigin, attach: r.attach,
2748
+ });
2749
+ return;
2750
+ }
2751
+ console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2752
+ console.log(` home: ${shortPath(r.home)}`);
2753
+ if (!r.launched) console.log(` launch: (cd ${shortPath(r.home)} && ${r.command})`);
2754
+ for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2755
+ console.log(` attach: ${r.attach}`);
2756
+ }
2757
+
2758
+ function retireCmd() {
2759
+ const name = args[1];
2760
+ if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
2761
+ const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
2762
+ if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
2763
+ if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
2764
+ let root = ensureRoot(dirFlag());
2765
+ // Cross-repo: the instance may home in a sibling repo of the team scope.
2766
+ if (!listAgents(root).some((a) => existsSync(join(a._dir, "instances", name)))) {
2767
+ const hit = findTeamInstance(dirFlag(), name);
2768
+ if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; console.log(`(cross-repo: instance homes at ${shortPath(root)})`); }
2769
+ }
2770
+ const r = retireInstance(root, name, { self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
2771
+ // Forced removal past an incomplete cleanup: the home is gone because the
2772
+ // operator said so, but the external state it owed is still out there and
2773
+ // nobody else will mention it again.
2774
+ if (r.forcedIncomplete) {
2775
+ console.error(`Removed ${r.retired} under --force with cleanup INCOMPLETE — this external state was NOT cleaned up and is now yours to remove by hand:`);
2776
+ for (const f of r.forcedIncomplete) console.error(` ${f}`);
2777
+ }
2778
+ if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); if (r.rollbackIncomplete) process.exit(1); return; }
2779
+ // An unsuccessful cleanup retry must NOT read as a completed retirement: the
2780
+ // home and its external state are still there, and a zero exit would tell
2781
+ // both a human and any script that the work is done.
2782
+ if (r.rollbackIncomplete) {
2783
+ console.error(`Cleanup for ${r.retired} is INCOMPLETE — the instance home is retained at ${r.retainedHome} because external state may still exist:`);
2784
+ for (const f of r.rollbackIncomplete) console.error(` ${f}`);
2785
+ console.error(`Fix the cause and re-run \`oats retire ${r.retired}\`; the home holds the state that cleanup needs.`);
2786
+ process.exit(1);
2787
+ }
2788
+ console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}${r.branchDeleted ? ", branch deleted" : ""}${r.harvested?.length ? `, harvested: ${r.harvested.join(", ")}` : ""}`);
2789
+ // Preserving work and not saying so leaves the operator believing it is gone,
2790
+ // which is most of the harm of deleting it. Name the classes and the path.
2791
+ for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
2792
+ console.log(`Work that was not committed has been preserved: ${recovery.classes.join(", ")}`);
2793
+ console.log(` ${recovery.path}`);
2794
+ }
2795
+ if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2796
+ }
2797
+
2798
+ async function paneCmd() {
2799
+ die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
2800
+ }
2801
+
2802
+ function createCmd() {
2803
+ const name = args[1];
2804
+ if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--runtime pi|claude] [--model <m>] [--instructions-file <f>]");
2805
+ const local = args.includes("--local");
2806
+ const startDir = dirFlag();
2807
+ // --local can BOOTSTRAP a deployment: with no agents/ or local-agents/ yet,
2808
+ // anchor at the enclosing git repo (else the start dir) — people can use OATS
2809
+ // with local agents alone.
2810
+ let root = findRoot(startDir);
2811
+ if (!root) {
2812
+ if (!local) root = ensureRoot(startDir); // keeps the pointed error for committed souls
2813
+ else root = join(defaultRepo(startDir) || resolve(startDir), "agents");
2814
+ }
2815
+ const instrFile = flag("instructions-file");
2816
+ const r = coreCreateAgent(root, {
2817
+ name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || defaultRepo(process.cwd()),
2818
+ work: flag("work"), runtime: flag("runtime"), model: flag("model"),
2819
+ instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
2820
+ });
2821
+ if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); return; }
2822
+ console.log(`Created ${r.kind === "local" ? "LOCAL agent (uncommitted — soul lives in local-agents/, gitignored)" : "agent"} "${r.agent}" — soul at ${shortPath(r.soul)}`);
2823
+ console.log(`Edit ${shortPath(join(r.soul, "AGENTS.md"))} to define its role, then: oats spawn ${r.agent} --task "..."`);
2824
+ }
2825
+
2826
+ // ---------- capability command dispatch ----------
2827
+ /**
2828
+ * oats <namespace> <command> [args…] — run a command an active capability
2829
+ * declares in its manifest (`commands: { name: "script args" }`).
2830
+ * Kernel subcommands take precedence over capability namespaces.
2831
+ */
2832
+ function capabilityCommand() {
2833
+ // JSON-aware boundary: in --json mode every dispatch failure — inactive or
2834
+ // untrusted capability, duplicate namespace, unknown subcommand, broken
2835
+ // metadata/manifests, malformed command values — must still emit exactly
2836
+ // one envelope object on stdout. The WHOLE dispatcher runs inside the
2837
+ // boundary; only "no namespace matched" escapes (returns false to the help
2838
+ // fallthrough).
2839
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2840
+ const NOT_DISPATCHED = Symbol("not-dispatched");
2841
+ let outcome;
2842
+ try { outcome = dispatch(); }
2843
+ catch (e) {
2844
+ // Unexpected throw from discovery/trust/decoding: keep the envelope contract.
2845
+ bail("E_CAPABILITY_BROKEN", e.message || e);
2846
+ throw e;
2847
+ }
2848
+ return outcome !== NOT_DISPATCHED;
2849
+
2850
+ function dispatch() {
2851
+ let activeIds;
2852
+ let context = process.cwd();
2853
+ let teamCtx;
2854
+ const instanceHome = process.env.PI_AGENT_HOME || process.env.OATS_HOME;
2855
+ const metaFile = instanceHome && join(instanceHome, "instance.json");
2856
+ // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
2857
+ // braces: the ids come from instance.json, which spawn wrote from resolved
2858
+ // manifests. Null-prototype because the dispatcher indexes it with the
2859
+ // namespace the operator typed on the command line.
2860
+ let capSettings = Object.create(null);
2861
+ try {
2862
+ if (metaFile && existsSync(metaFile)) {
2863
+ const meta = JSON.parse(readFileSync(metaFile, "utf8"));
2864
+ activeIds = (meta.capabilities || []).map((c) => c.id);
2865
+ for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
2866
+ context = meta.repo || context;
2867
+ // Team: the spawn-time snapshot, but fall back to live config — instances
2868
+ // spawned before a team: block was declared have no snapshot.
2869
+ teamCtx = meta.team || resolveOatsConfig(context).team;
2870
+ } else {
2871
+ const resolved = resolveOatsConfig(context, flag("soul"));
2872
+ activeIds = resolved.capabilities.map((c) => c.id);
2873
+ for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
2874
+ teamCtx = resolved.team;
2875
+ }
2876
+ } catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
2877
+ const mans = Object.values(capabilityManifests(context)).filter((m) => m.command === cmd && m.commands);
2878
+ if (!mans.length) return NOT_DISPATCHED;
2879
+ if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
2880
+ const m = mans[0];
2881
+ if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
2882
+ const trust = capabilityTrust(m, context);
2883
+ if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
2884
+ const sub = args[1];
2885
+ const cmds = Object.keys(m.commands);
2886
+ // Distinguish an ABSENT key from a declared-but-invalid value: a manifest
2887
+ // entry of "" / 0 / false / null is a broken capability, not an unknown
2888
+ // command (it is listed in cmds).
2889
+ if (!sub || !Object.prototype.hasOwnProperty.call(m.commands, sub)) {
2890
+ if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `oats ${cmd}: ${sub ? `unknown command "${sub}"` : "missing command"} — commands: ${cmds.join(", ") || "(none)"}`);
2891
+ console.error(`oats ${cmd} — commands: ${cmds.join(", ") || "(none)"}`);
2892
+ process.exit(sub ? 1 : 0);
2893
+ }
2894
+ // Command values come from third-party manifests — validate before decoding.
2895
+ const spec = m.commands[sub];
2896
+ if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: manifest command must be a non-empty string (got ${JSON.stringify(spec)})`);
2897
+ const [script, ...rest] = spec.trim().split(/\s+/);
2898
+ let abs;
2899
+ try { abs = capabilityExecutablePath(m, script); }
2900
+ catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
2901
+ if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(m._dir, script)})`);
2902
+ const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
2903
+ ...process.env, OATS_CAPABILITY: m.capability,
2904
+ // Package-runtime boundary: dispatched commands receive the active
2905
+ // capability's EFFECTIVE settings (instance snapshot or resolved context),
2906
+ // same contract as lifecycle hooks — capabilities read their settings
2907
+ // here instead of importing the kernel resolver.
2908
+ OATS_SETTINGS: JSON.stringify(capSettings[m.capability] || {}),
2909
+ // PATH is not a trusted runtime boundary (maintainer finding 1): pass the
2910
+ // canonical absolute executable of THIS CLI; official consumers execFile
2911
+ // it directly and never resolve `oats` from PATH or a shell.
2912
+ OATS_CLI_BIN: CLI_BIN,
2913
+ OATS_TEAM_NAME: teamCtx?.name || "", OATS_TEAM_ID: teamCtx?.id || "", OATS_TEAM_SCOPE: teamCtx?.scope || "",
2914
+ } });
2915
+ // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
2916
+ if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
2917
+ process.exit(r.status ?? 1);
2918
+ }
2919
+ }
2920
+
2921
+ // ---------- agent types ----------
2922
+ function typeCmd() {
2923
+ const sub = args[1];
2924
+ const dir = dirFlag();
2925
+ const file = join(dir, "oats-config.yaml");
2926
+ if (sub === "list") {
2927
+ const seen = new Map();
2928
+ for (const cfg of configChain(dir)) for (const [name, spec] of Object.entries(cfg["agent-types"] || {})) if (!seen.has(name)) seen.set(name, { desc: spec?.description, level: cfg._level });
2929
+ if (!seen.size) { console.log("No agent types declared in the config chain."); return; }
2930
+ for (const [name, { desc, level }] of seen) console.log(`${name} ${desc ? `— ${desc} ` : ""}[${shortPath(level)}]`);
2931
+ return;
2932
+ }
2933
+ if (sub !== "add" || !args[2] || args[2].startsWith("--")) die("usage: oats type add <name> [--description <d>] [--dir <dir>] | oats type list [--dir <dir>]");
2934
+ const name = args[2];
2935
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) die(`agent type "${name}" must be lowercase alphanumeric/hyphens`);
2936
+ const description = flag("description");
2937
+ let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
2938
+ const cfg = existsSync(file) ? withConfigFile(file, () => parseYamlNested(text)) : {};
2939
+ // Own-property: `constructor` is a legal agent-type name, and a plain lookup
2940
+ // would report it as already declared in a config that never mentions it.
2941
+ const declaredTypes = cfg["agent-types"];
2942
+ if (declaredTypes && typeof declaredTypes === "object" && Object.hasOwn(declaredTypes, name)) die(`agent type "${name}" already declared in ${shortPath(file)}`);
2943
+ // The NAME is already held to a strict grammar above; the DESCRIPTION was
2944
+ // written verbatim onto its own line, so it could inject document the same
2945
+ // way a `--settings` value could.
2946
+ const block = [` ${name}:`, ...(description ? [` description: ${assertSafeConfigValue(description, "--description")}`] : [])];
2947
+ const lines = text.replace(/\n*$/, "\n").split("\n");
2948
+ // Drop the scaffold comment block once a real agent-types block exists.
2949
+ const scaffold = lines.findIndex((l) => /^# ── Agent types/.test(l));
2950
+ if (scaffold >= 0) {
2951
+ let e = scaffold;
2952
+ while (e < lines.length && (/^#/.test(lines[e]) || lines[e] === "")) { if (lines[e] === "" && !/^#/.test(lines[e + 1] || "x")) break; e++; }
2953
+ lines.splice(scaffold, e - scaffold);
2954
+ }
2955
+ const start = lines.findIndex((l) => /^agent-types:\s*(#.*)?$/.test(l));
2956
+ if (start >= 0) {
2957
+ let end = start + 1;
2958
+ while (end < lines.length && (/^\s/.test(lines[end]) || lines[end] === "")) { if (lines[end] === "" && !/^\s/.test(lines[end + 1] || "x")) break; end++; }
2959
+ lines.splice(end, 0, ...block);
2960
+ } else {
2961
+ lines.splice(1, 0, "", "agent-types:", ...block);
2962
+ }
2963
+ writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"));
2964
+ console.log(`Declared agent type "${name}" at ${levelOf(dir)} level (${shortPath(file)})`);
2965
+ console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
2966
+ }
2967
+
2968
+ // ---------- injection eject ----------
2969
+ function injectCmd() {
2970
+ const sub = args[1];
2971
+ const target = args[2];
2972
+ if (sub !== "eject" || !target || target.startsWith("--")) die("usage: oats inject eject <capability-id|oats> [--dir <dir>]");
2973
+ const dir = dirFlag();
2974
+ const file = join(dir, "oats-config.yaml");
2975
+ if (!existsSync(file)) die(`no oats-config.yaml at ${shortPath(dir)} — run oats init first`);
2976
+ if (["checkout", "worktree", "attached", "workspace"].includes(target)) die("work-mode injection overrides were removed — the packaged briefings are the contract; work modes support only setup: (env bootstrap script)");
2977
+ const isWorkMode = false;
2978
+ const isKernel = target === "oats";
2979
+ const src = isKernel ? packagedInject("oats", dir) : isWorkMode ? packagedInject(`work-${target}`, dir) : packagedInject(target, dir);
2980
+ if (!src) die(`no packaged default injection found for "${target}"`);
2981
+ const rel = isKernel ? ".agents/injections/oats-defaults/oats.md" : isWorkMode ? `.agents/injections/workmodes/${target}.md` : `.agents/injections/capabilities/${target}.md`;
2982
+ const destAbs = join(dir, rel);
2983
+ if (existsSync(destAbs)) die(`${shortPath(destAbs)} already exists — edit it directly (it is already your override)`);
2984
+ let text = readFileSync(file, "utf8");
2985
+ if (!isWorkMode && !isKernel) {
2986
+ const caps = readCapabilitiesModel(file);
2987
+ const entry = Object.values(caps.layers).find((e) => e && e !== "none" && e.capability === target) || caps.additive[target];
2988
+ if (!entry) die(`capability "${target}" has no entry in ${shortPath(file)} — activate it first (oats use ${target})`);
2989
+ const m = capabilityManifest(target, dir);
2990
+ const owned = entry.from === "owned" || String(entry.from || "").startsWith("path:") || String(m?._origin || "").startsWith("owned:") || String(m?._origin || "").startsWith("path:");
2991
+ if (owned) die(`"${target}" is owned/path-sourced — you own its source; edit its injects/ file directly instead of ejecting`);
2992
+ entry["injection-override"] = rel;
2993
+ text = replaceCapabilitiesBlock(text, caps);
2994
+ } else {
2995
+ const lines = text.replace(/\n*$/, "\n").split("\n");
2996
+ const headRe = isKernel ? /^oats:\s*(#.*)?$/ : /^work-modes:\s*(#.*)?$/;
2997
+ let idx = lines.findIndex((l) => headRe.test(l));
2998
+ if (idx < 0) { lines.push("", isKernel ? "oats:" : "work-modes:"); idx = lines.length - 1; }
2999
+ if (isKernel) {
3000
+ lines.splice(idx + 1, 0, ` injection-override: ${rel}`);
3001
+ const c = lines.findIndex((l, i2) => i2 > idx + 1 && l.trim() === `# injection-override: ${rel}`);
3002
+ if (c >= 0) lines.splice(c, 1);
3003
+ } else {
3004
+ let mIdx = lines.findIndex((l, i2) => i2 > idx && new RegExp(`^ ${target}:`).test(l));
3005
+ if (mIdx < 0) { lines.splice(idx + 1, 0, ` ${target}:`, ` injection-override: ${rel}`); }
3006
+ else {
3007
+ lines.splice(mIdx + 1, 0, ` injection-override: ${rel}`);
3008
+ const c = lines.findIndex((l, i2) => i2 > mIdx + 1 && l.trim() === `# injection-override: ${rel}`);
3009
+ if (c >= 0) lines.splice(c, 1);
3010
+ }
3011
+ }
3012
+ text = lines.join("\n").replace(/\n*$/, "\n");
3013
+ }
3014
+ mkdirSync(dirname(destAbs), { recursive: true });
3015
+ writeFileSync(destAbs, readFileSync(src, "utf8"));
3016
+ writeFileSync(file, text);
3017
+ console.log(`Ejected packaged injection → ${shortPath(destAbs)}`);
3018
+ console.log(`Set injection-override in ${shortPath(file)}. Edit the ejected file; it no longer tracks package updates.`);
3019
+ }
3020
+
3021
+ // ---------- update ----------
3022
+ function updateCmd() {
3023
+ const checkOnly = args.includes("--check");
3024
+ let latest;
3025
+ try { latest = execFileSync("npm", ["view", "@awebai/oats", "version"], { encoding: "utf8", timeout: 30000 }).trim(); }
3026
+ catch (e) { die(`cannot check npm for the latest version: ${e.message}`); }
3027
+ console.log(`@awebai/oats installed: ${OATS_VERSION} latest: ${latest}`);
3028
+ // pi bridge, if a pi installation carries it.
3029
+ let piBridge;
3030
+ const piPkg = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
3031
+ if (existsSync(piPkg)) piBridge = JSON.parse(readFileSync(piPkg, "utf8")).version;
3032
+ if (piBridge) console.log(`@awebai/oats-pi installed: ${piBridge} latest: ${latest} (published in lockstep)`);
3033
+ if (latest === OATS_VERSION && (!piBridge || piBridge === latest)) { console.log("Up to date."); return; }
3034
+ const steps = [];
3035
+ if (latest !== OATS_VERSION) steps.push(`npm install -g @awebai/oats@${latest}`);
3036
+ if (piBridge && piBridge !== latest) steps.push(`pi uninstall npm:@awebai/oats-pi@${piBridge}`, `pi install npm:@awebai/oats-pi@${latest}`);
3037
+ console.log("\nUpdate steps:");
3038
+ for (const s of steps) console.log(` ${s}`);
3039
+ if (checkOnly) { console.log("\n(--check: not executing)"); return; }
3040
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
3041
+ if (interactive) {
3042
+ process.stdout.write("\nRun these now? [y/N] ");
3043
+ const buf = Buffer.alloc(16);
3044
+ let answer = "";
3045
+ try { answer = buf.toString("utf8", 0, readSync(0, buf, 0, 16)).trim().toLowerCase(); } catch { /* no input */ }
3046
+ if (answer !== "y" && answer !== "yes") { console.log("Not updating."); return; }
3047
+ } else if (!args.includes("--yes")) {
3048
+ console.log("\nNon-interactive: pass --yes to execute, or run the steps yourself.");
3049
+ return;
3050
+ }
3051
+ for (const s of steps) {
3052
+ console.log(`\n$ ${s}`);
3053
+ const [bin, ...rest] = s.split(/\s+/);
3054
+ const r = spawnSync(bin, rest, { stdio: "inherit" });
3055
+ if (r.status !== 0) die(`step failed: ${s}`);
3056
+ }
3057
+ console.log(`\nUpdated to ${latest}. Now verify each deployment: run \`oats doctor\` at your workspace/repo scopes — it reports config spellings this version rejects, version skew, and missing requirements. Restart running pi sessions to pick up the new bridge.`);
3058
+ }
3059
+
3060
+ // ---------- version (Desktop CLI API v1 probe) ----------
3061
+ function versionCmd() {
3062
+ if (JSON_MODE) {
3063
+ // EXACT Desktop API v1 probe payload — one JSON object, nothing else on
3064
+ // stdout. Desktop accepts desktopApi === 1 and a compatible semver range.
3065
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1 }));
3066
+ return;
3067
+ }
3068
+ console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
3069
+ }
3070
+
3071
+ // ---------- the record (core) and experimental tools over it ----------
3072
+ // The turn record is the core: capture, recall, setup are kernel-level
3073
+ // subcommands, dispatched to packages/record (shipped inside this package —
3074
+ // see "files" in package.json). The record bins parse process.argv.slice(2)
3075
+ // themselves, so the consumed subcommand words are spliced out first.
3076
+ // Everything that selects or synthesizes over the record (dress, spawn,
3077
+ // segments, mind) is EXPERIMENTAL and ships only in the oats repo checkout,
3078
+ // under packages/experimental — absent from the published tarball on
3079
+ // purpose, so its presence is exactly its status.
3080
+ const EXPERIMENTAL_CMDS = new Set(["dress", "spawn", "segments", "mind"]);
3081
+ async function recordCmd(sub) {
3082
+ process.argv.splice(2, 1);
3083
+ await import(new URL(`../packages/record/bin/${sub}.mjs`, import.meta.url));
3084
+ }
3085
+ async function experimentalCmd() {
3086
+ const sub = args[1];
3087
+ if (!sub || !EXPERIMENTAL_CMDS.has(sub)) {
3088
+ console.error(
3089
+ "usage: oats experimental <dress|spawn|segments|mind> [options]\n" +
3090
+ "EXPERIMENTAL tools over the turn record — unproven by design; see packages/experimental/README.md",
3091
+ );
3092
+ process.exit(sub === undefined ? 0 : 2);
3093
+ }
3094
+ const url = new URL(`../packages/experimental/bin/${sub}.mjs`, import.meta.url);
3095
+ if (!existsSync(url)) {
3096
+ die(
3097
+ `experimental tools ship only in the oats repo checkout, not in the published package — clone github.com/awebai/oats and run \`oats experimental ${sub}\` from it`,
3098
+ );
3099
+ }
3100
+ process.argv.splice(2, 2);
3101
+ await import(url);
3102
+ }
3103
+
3104
+ // ---------- main ----------
3105
+ // Typed config-shape failures are DEPLOYMENT state the operator can fix, not
3106
+ // kernel bugs: an unsafe mapping key anywhere in the visible config chain is
3107
+ // raised by the readers, which every command walks before it can do anything.
3108
+ // Without this boundary `oats doctor`, `oats use` and every --json mode printed a
3109
+ // raw Node stack with empty stdout — no code, no envelope, nothing to act on.
3110
+ // Deliberately narrow: only codes with a defined rendering are caught here;
3111
+ // anything else still crashes loudly.
3112
+ //
3113
+ // The dispatch chain below is deliberately NOT re-indented into this try block:
3114
+ // keeping it at column 0 makes the whole command table one reviewable diff of
3115
+ // added lines rather than ~150 lines of pure whitespace churn, and keeps `git
3116
+ // blame` pointing at the commit that last changed each command.
3117
+ const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3118
+ try {
3119
+ if (cmd === "doctor") {
3120
+ const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
3121
+ args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
3122
+ }
3123
+ else if (cmd === "use") use();
3124
+ else if (cmd === "update") { const t = args[1] && !args[1].startsWith("--") ? args[1] : undefined; t ? updatePackageCmd(t) : updateCmd(); }
3125
+ else if (cmd === "type") typeCmd();
3126
+ else if (cmd === "inject") injectCmd();
3127
+ else if (cmd === "install") install();
3128
+ else if (cmd === "config") configCmd();
3129
+ else if (cmd === "trust") trust();
3130
+ else if (cmd === "list") listCmd();
3131
+ else if (cmd === "remove") removeCmd();
3132
+ else if (cmd === "migrate") migrateCmd();
3133
+ else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
3134
+ else if (cmd === "init") init();
3135
+ else if (cmd === "status") status();
3136
+ else if (cmd === "pane") await paneCmd();
3137
+ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
3138
+ // Same rule as the inner catch: a typed CLI failure surfaces with its own code
3139
+ // through the shared boundary, never re-badged as a spawn-mechanism failure.
3140
+ else if (cmd === "spawn") { try { spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3141
+ else if (cmd === "retire") retireCmd();
3142
+ else if (cmd === "create") createCmd();
3143
+ else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
3144
+ else if (cmd === "experimental") await experimentalCmd();
3145
+ // `!HELP_WORDS.has(cmd)`: usage NEVER depends on deployment state. `help` is a
3146
+ // word, so without this it reaches the capability dispatch, which resolves the
3147
+ // config chain and reads every lock in it — and a scope whose lock the kernel
3148
+ // refuses could then not print its own usage, which is exactly when you need it.
3149
+ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
3150
+ // No matching kernel command or capability namespace: in --json mode the help
3151
+ // text must NOT contaminate stdout — still one envelope object, nonzero exit.
3152
+ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `unknown command "${cmd}" — no kernel subcommand or active capability namespace matches`);
3153
+ else {
3154
+ console.log(`oats — Open Agent Team Specification
3155
+
3156
+ Usage:
3157
+ oats version [--json] kernel version; --json emits the
3158
+ Desktop CLI API v1 probe payload
3159
+ oats status [--json] agents, souls, running instances
3160
+ oats status --team [--json] whole-team roster across the team scope's repos
3161
+ oats create <name> [--local] create an agent soul; --local = full
3162
+ [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3163
+ [--work <mode>] [--runtime pi|claude] gitignored; same memory + lifecycle)
3164
+ [--model <m>] [--instructions-file <f>]
3165
+ oats spawn <agent> [--task <text>] spawn an instance (tmux; --no-launch
3166
+ [--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
3167
+ [--parent <instance>] --def-file creates a local agent;
3168
+ [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3169
+ [--relative-to <instance>] new instance to an existing one; --parent X
3170
+ [--relative-root <agents-root>] disambiguates same-named team anchors
3171
+ [--work worktree|checkout|attached|workspace] = sugar for --relative-to X --relation
3172
+ [--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3173
+ [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]
3174
+ with team: declared, unknown local souls
3175
+ resolve across the team scope's repos
3176
+ oats retire <instance> [--force] retire an instance (window, hooks,
3177
+ [--self] [--delete-branch] worktree, home); --self = retire the
3178
+ [--keep-dir] [--json] CALLING instance (delayed window kill)
3179
+ oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
3180
+ --soul shows final composed AGENTS.md
3181
+ oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
3182
+ optionally run the update; then run oats doctor
3183
+ oats install [<source>] [--dir <d>] acquire + exact-lock a package closure
3184
+ (git:host/org/repo@ref[#<path>], git URL,
3185
+ local path, official catalog id) or a legacy
3186
+ marketplace capability; never activates
3187
+ #<path> selects the contained package root
3188
+ (default oats-package; #. = repository root;
3189
+ local paths are always exact directories)
3190
+ [--recursive] [--no-requirements] bare \`oats install\` exactly restores this
3191
+ [--accept-requirement <cmd> ...] chain's locked packages + capabilities; at a
3192
+ [--json] team: scope (or with --recursive) it reconciles
3193
+ the whole workspace — descendant scopes restore
3194
+ once in path order (pruned discovery), then the
3195
+ host-requirement consent gate runs;
3196
+ --no-requirements = package-only (CI);
3197
+ non-interactive runs never install host tools
3198
+ unless each requirement is named explicitly;
3199
+ --json = one envelope (failures carry the full
3200
+ report under error.details)
3201
+ oats list [--dir <d>] [--json] installed packages, exported capabilities,
3202
+ scopes, trust state
3203
+ oats update <package> [--dir <d>] transactional package update: temp fetch,
3204
+ closure validation, diff, lock replace,
3205
+ all capability approvals invalidated
3206
+ oats remove <package> [--dir <d>] remove a package (refuses while config or
3207
+ dependent packages reference it)
3208
+ oats migrate [--dry-run] [--dir <d>] map this scope's v1 capability locks to
3209
+ package locks (preserves config activation)
3210
+ oats migrate --official [--recursive] guided upgrade of 0.18 bundled official
3211
+ [--dry-run] [--dir <d>] [--json] capabilities to official packages: plans every
3212
+ visible lock-owning scope first, applies each
3213
+ transactionally, keeps custom/owned entries
3214
+ untouched, and prints the exact trust/install
3215
+ follow-up (held when the catalog cannot map yet)
3216
+ oats migrate --from-oas [--recursive] convert a pre-rename OAS deployment in place:
3217
+ [--dry-run] [--dir <d>] [--json] renames oas-* files, the oas: config key and
3218
+ capability ids, then chains the guided package
3219
+ conversion — one transaction per scope, any
3220
+ failure restores the original OAS bytes
3221
+ oats config diff [--config <template>] three-way report: your config vs the recorded
3222
+ [--dir <d>] [--json] adopted base vs the template in the current exact
3223
+ lock — reports only, never writes; the adopted
3224
+ base supplies the package/template defaults
3225
+ oats config sync [--accept <r>=local|package] apply the template's changes to your config,
3226
+ [--dir <d>] [--json] region by region, preserving every untouched local
3227
+ byte, comment and ordering; local-only edits stay;
3228
+ conflicts need an explicit --accept and are never
3229
+ chosen for you; advances the recorded base
3230
+ oats config sync --reset --yes replace your config with the template verbatim;
3231
+ [--config <template>] [--dir <d>] previews every local change it discards, refuses
3232
+ [--json] without --yes, and keeps a recoverable .bak
3233
+ oats config adopt <package> switch to another installed package's template,
3234
+ [--config <template>] [--accept ...] rebasing your one local config; exactly one adopted
3235
+ [--dir <d>] [--json] base survives, and a failed switch changes nothing
3236
+ oats trust <capability> [--dir <dir>] approve that capability's commands, hooks, and
3237
+ launch-environment authority at
3238
+ the provider package's exact integrity
3239
+ oats trust <package> --all-capabilities explicit bulk approval with a full
3240
+ executable-surface summary
3241
+ oats use <capability> activate for one config-owned target
3242
+ [--global|--type <t>|--soul <s>] (--global is default); --disable excludes
3243
+ [--disable] [--settings k=v [k2=v2 ...]] [--dir <d>]
3244
+ oats use none --layer <layer> explicitly disable a fundamental layer
3245
+ oats type add <name> [--description <d>] declare an agent type (family) in config;
3246
+ oats type list souls join via create --type / soul.yaml
3247
+ oats inject eject <cap|work-mode|oats> copy a packaged injection to the conventional
3248
+ [--dir <d>] .agents/injections/ path and set injection-override
3249
+ oats init [--raw] [--dir <dir>] [--json] create an oats-config.yaml here. Fundamental
3250
+ [--knowledge <id|none>] layers are filled from what is already at this
3251
+ [--messaging <id|none>] scope, else acquired from the official package
3252
+ [--tasks <id|none>] that supplies them — capabilities materialize
3253
+ [--tmux-mouse|--no-tmux-mouse] flat, executable surfaces stay untrusted, and
3254
+ the whole run rolls back on any failure.
3255
+ [--package <id|path|git-url>] instead: adopt one config TEMPLATE from a package
3256
+ [--config <template>] as your own local config and record the exact
3257
+ adopted base (named template, else the marked
3258
+ default, else the only one).
3259
+ [--template <name|path|git-url>] instead: seed from a template config (named via an
3260
+ outer templates: map, a local file, or a git repo's
3261
+ default-branch oats-config.yaml).
3262
+ Every form refuses to overwrite an existing config;
3263
+ --json = exactly one result envelope, noninteractive.
3264
+ oats root print this package's install root
3265
+ (adapters resolve the kernel from it)
3266
+
3267
+ The turn record (core — every conversation captured, searchable, replicated):
3268
+ oats capture [--watch|--status] land Claude Code/pi/codex sessions and aw
3269
+ [--owner <name>] [--root <dir>] client logs in the record; reconciliation
3270
+ is the capture
3271
+ oats recall [--kind k] [--thread t] search the whole record — mail, chat,
3272
+ [--from f] [--show id] <query> sessions — with exact turn provenance
3273
+ oats setup [--owner <name>] [--dry-run] install capture hooks + background watcher
3274
+ [--no-service] [--no-hooks] (launchd/systemd), then run the first pass
3275
+
3276
+ oats experimental <dress|spawn|segments|mind> EXPERIMENTAL tools over the record —
3277
+ selection and agent synthesis; unproven by
3278
+ design, repo checkout only; see
3279
+ packages/experimental/README.md
3280
+
3281
+ oats <namespace> <command> [args…] run an operational command only when its
3282
+ capability is active (e.g. oats okf harvest)
3283
+
3284
+ Layers: ${LAYERS.join(", ")}. Level detection: ~ → laptop, .git → repo, else workspace.`);
3285
+ process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
3286
+ }
3287
+ } catch (e) {
3288
+ if (!TYPED_CLI_FAILURES.has(e?.code)) throw e;
3289
+ // Same two renderings as every other typed failure: one envelope on stdout in
3290
+ // --json mode, one `oats: <message>` line on stderr otherwise. The message
3291
+ // already names the offending file — the readers re-raise it with one.
3292
+ if (JSON_MODE) jsonFail(e.code, e.message);
3293
+ die(e.message);
3294
+ }