@awebai/oats 0.25.8 → 0.26.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 (185) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +334 -72
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +107 -5
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +14 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-okf/bin/oats-okf.mjs +1 -1
  20. package/capabilities/oats-okf/lib/binding-wire.mjs +1 -1
  21. package/capabilities/oats-okf/lib/migration.mjs +2 -2
  22. package/capabilities/oats-okf/lib/sources.mjs +5 -4
  23. package/capabilities/oats-okf/lib/stores.mjs +40 -9
  24. package/capabilities/oats-okf/lib/worker.mjs +3 -3
  25. package/capabilities/oats-okf/oats.json +1 -1
  26. package/capabilities/oats-review/oats.json +3 -2
  27. package/docs/capabilities.md +218 -47
  28. package/docs/capability-manifest.schema.json +13 -4
  29. package/docs/configuration.md +17 -5
  30. package/docs/conventions.md +16 -26
  31. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  32. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  33. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  34. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  35. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  36. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  37. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  38. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  39. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +54 -10
  40. package/docs/design/2026-09-24-phase-d-plan.md +77 -0
  41. package/docs/design/2026-09-25-teams-contract.md +226 -0
  42. package/docs/design/README.md +3 -3
  43. package/docs/design/launch-configurations.md +20 -16
  44. package/docs/design/operations-contract.md +27 -10
  45. package/docs/desktop-cli-api.md +546 -264
  46. package/docs/desktop-instance-start.md +1 -1
  47. package/docs/desktop.md +7 -13
  48. package/docs/execution-targets.md +16 -18
  49. package/docs/first-team.md +14 -17
  50. package/docs/implementation.md +28 -59
  51. package/docs/integrations.md +88 -32
  52. package/docs/knowledge-capability-authoring.md +1 -1
  53. package/docs/knowledge-reference/package-craft.md +10 -8
  54. package/docs/knowledge-theory.md +1 -1
  55. package/docs/knowledge.md +10 -11
  56. package/docs/layers.md +16 -17
  57. package/docs/oats-local.schema.json +29 -1
  58. package/docs/oats-membership.schema.json +5 -3
  59. package/docs/oats-package.schema.json +2 -2
  60. package/docs/oats-workspace.schema.json +1 -1
  61. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  62. package/docs/packages.md +75 -52
  63. package/docs/release-notes/v0.22.0.md +1 -1
  64. package/docs/release-notes/v0.23.1.md +1 -1
  65. package/docs/release-notes/v0.25.9.md +23 -0
  66. package/docs/release-notes/v0.26.0.md +670 -0
  67. package/docs/schedules.md +48 -126
  68. package/docs/soul.schema.json +11 -4
  69. package/docs/souls-and-instances.md +56 -43
  70. package/docs/workspaces.md +80 -58
  71. package/injects/instance-boundary.md +1 -1
  72. package/injects/work-attached.md +1 -1
  73. package/injects/work-workspace.md +2 -2
  74. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  75. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  76. package/lib/capability-contract.mjs +110 -0
  77. package/lib/config-data.mjs +2 -2
  78. package/lib/core.mjs +700 -4824
  79. package/lib/digest.mjs +12 -0
  80. package/lib/instance-inspect.mjs +396 -0
  81. package/lib/instance-lifecycle.mjs +3 -4
  82. package/lib/instance-resolution.mjs +212 -26
  83. package/lib/instruction-composition.mjs +0 -20
  84. package/lib/materialize.mjs +6 -4
  85. package/lib/operator-dispatch.mjs +33 -13
  86. package/lib/packages.mjs +25 -190
  87. package/lib/provider-binding.mjs +4 -2
  88. package/lib/provider-reasons.mjs +3 -68
  89. package/lib/resolve.mjs +204 -68
  90. package/lib/schedule.mjs +97 -272
  91. package/lib/servers.mjs +13 -13
  92. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  93. package/lib/tree-copy.mjs +44 -0
  94. package/lib/workspace.mjs +125 -20
  95. package/package-catalog.json +7 -7
  96. package/package.json +1 -1
  97. package/skills/integration-authoring/SKILL.md +48 -40
  98. package/skills/oats-getting-started/SKILL.md +105 -110
  99. package/skills/oats-support/SKILL.md +2 -2
  100. package/skills/soul-craft/SKILL.md +13 -6
  101. package/bin/oats-pi-sdk-host.mjs +0 -17
  102. package/docs/2026-09-03-architecture-proposal.md +0 -642
  103. package/docs/artifact-approvals.schema.json +0 -7
  104. package/docs/captured-invocation-context.schema.json +0 -7
  105. package/docs/captured-resolution.schema.json +0 -7
  106. package/docs/design/package-engine-contract.md +0 -813
  107. package/docs/design/package-runtime-api.md +0 -588
  108. package/docs/desktop-succession.md +0 -57
  109. package/docs/execution-capsule.schema.json +0 -108
  110. package/docs/first-team-demo.md +0 -92
  111. package/docs/knowledge-migration.md +0 -147
  112. package/docs/migration-from-oas.md +0 -103
  113. package/docs/oats-config.schema.json +0 -172
  114. package/docs/oats-lock-v3.schema.json +0 -7
  115. package/docs/oats-lock.schema.json +0 -175
  116. package/docs/operating-team-migration.md +0 -470
  117. package/docs/portable.schema.json +0 -2512
  118. package/docs/provider-check-input.schema.json +0 -7
  119. package/docs/rebuild-to-v2.md +0 -511
  120. package/docs/workspace-adoption.md +0 -74
  121. package/injects/framework-workspace.md +0 -7
  122. package/injects/local-soul.md +0 -19
  123. package/injects/oats-portable.md +0 -20
  124. package/injects/oats.md +0 -11
  125. package/injects/portable-instance-boundary.md +0 -39
  126. package/injects/portable-work-directory.md +0 -29
  127. package/lib/artifact-approvals.mjs +0 -120
  128. package/lib/artifact-tree.mjs +0 -141
  129. package/lib/capability-artifacts.mjs +0 -179
  130. package/lib/capability-execution.mjs +0 -15
  131. package/lib/capability-inputs.mjs +0 -39
  132. package/lib/capability-provenance.mjs +0 -231
  133. package/lib/captured-action-shape.mjs +0 -21
  134. package/lib/captured-admission-shape.mjs +0 -20
  135. package/lib/captured-binding-file.mjs +0 -36
  136. package/lib/captured-dispatch.mjs +0 -66
  137. package/lib/captured-instance-index.mjs +0 -277
  138. package/lib/captured-invocation-context.mjs +0 -130
  139. package/lib/captured-launch-request.mjs +0 -66
  140. package/lib/captured-operation-process.mjs +0 -15
  141. package/lib/captured-pi-custody.mjs +0 -29
  142. package/lib/captured-pi-host.mjs +0 -167
  143. package/lib/captured-pi-outcome.mjs +0 -172
  144. package/lib/captured-resolutions.mjs +0 -275
  145. package/lib/captured-scaffold.mjs +0 -87
  146. package/lib/captured-selector.mjs +0 -28
  147. package/lib/captured-session-backend.mjs +0 -52
  148. package/lib/captured-source-receipt-file.mjs +0 -72
  149. package/lib/helper-injection-policy.mjs +0 -104
  150. package/lib/legacy-lock-codec.mjs +0 -106
  151. package/lib/manifest-settings.mjs +0 -84
  152. package/lib/package-closure.mjs +0 -48
  153. package/lib/package-materialization.mjs +0 -83
  154. package/lib/pi-sdk-host.mjs +0 -229
  155. package/lib/portable-artifacts.mjs +0 -115
  156. package/lib/portable-choices.mjs +0 -82
  157. package/lib/portable-composition.mjs +0 -136
  158. package/lib/portable-digest.mjs +0 -105
  159. package/lib/portable-identity.mjs +0 -40
  160. package/lib/portable-lock.mjs +0 -117
  161. package/lib/portable-onboarding-request.mjs +0 -49
  162. package/lib/portable-onboarding.mjs +0 -256
  163. package/lib/portable-package-preparation.mjs +0 -188
  164. package/lib/portable-policy.mjs +0 -44
  165. package/lib/portable-soul.mjs +0 -42
  166. package/lib/portable-state.mjs +0 -80
  167. package/lib/prepare-composition.mjs +0 -170
  168. package/lib/prepared-bindings.mjs +0 -92
  169. package/lib/prepared-resources.mjs +0 -127
  170. package/lib/provider-binding-broker.mjs +0 -65
  171. package/lib/provider-binding-wire.mjs +0 -116
  172. package/lib/readiness.mjs +0 -225
  173. package/lib/repository-observation.mjs +0 -226
  174. package/lib/resolution-shape.mjs +0 -393
  175. package/lib/schedule-capsule.mjs +0 -206
  176. package/lib/soul-constraints.mjs +0 -40
  177. package/lib/source-projection.mjs +0 -84
  178. package/lib/source-spec.mjs +0 -189
  179. package/lib/workspace-definition.mjs +0 -126
  180. package/lib/workspace-discovery.mjs +0 -146
  181. package/skills/oats/SKILL.md +0 -162
  182. package/skills/oats-config/SKILL.md +0 -164
  183. package/skills/oats-packages/SKILL.md +0 -184
  184. package/skills/oats-portable/SKILL.md +0 -115
  185. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/bin/oats.mjs CHANGED
@@ -2,13 +2,13 @@
2
2
  /**
3
3
  * oats — the OATS command line.
4
4
  *
5
- * oats doctor [dir] [--json] show the resolved config with origins
5
+ * oats doctor [dir] [--soul <s>] [--json] show the deployment; --soul: the instructions an instance of <s> would carry
6
6
  * oats onboard [<dir>] --workspace <ref> realize a workspace here (oats-local.yaml +
7
7
  * agents/), then sync
8
8
  * oats sync [--dir <d>] [--json] discover the workspace, confirm membership,
9
- * resolve packages, approve, write the lock
9
+ * resolve packages, write the lock
10
10
  * oats package add|remove ... edit `packages:` in the workspace file
11
- * oats workspace status membership table, packages, approval
11
+ * oats workspace status membership table, packages
12
12
  * oats capabilities | oats souls every visible item of the workspace
13
13
  *
14
14
  * Workspace model v2 (docs/design/2026-09-23-workspace-module-contracts.md §6):
@@ -17,30 +17,23 @@
17
17
  * `init` / `use` / `install` / `restore` / `list` / `catalog` / `remove` /
18
18
  * `migrate` / `trust` / `inject` are gone with the installed-capability tier.
19
19
  */
20
- import { existsSync, lstatSync, mkdirSync, readFileSync, readSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
21
21
  import { execFileSync, spawnSync } from "node:child_process";
22
- import { homedir } from "node:os";
22
+ import { homedir, tmpdir } from "node:os";
23
23
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
24
- import { createHash } from "node:crypto";
25
24
  import { fileURLToPath } from "node:url";
26
25
  import {
27
- LAYERS, WORK_MODES, LEGACY_HOME_CAPABILITIES_DIR, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
28
- capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityTrust, capabilityExecutablePath, activateCapturedScaffold, loadCapturedDispatch, inspectPortableOnboarding, prepareCapturedComposition, resolveCapturedHelper, capturedNativeSessionAvailability, scaffoldCapturedInstance, startCapturedInstanceSession, withCapturedBindingFile, withCapturedInvocationContextFile,
29
- readCapabilityLocks, admitCapturedAction, beginCapturedIntent, settleCapturedIntent, listInstalledPackages, readPackageLocks,
30
- officialPackageCatalog, describeOfficialCatalog, approveAvailableCapability,
31
- packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir,
32
- resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, stripInternalAnnotations, withConfigFile, teamAgentRoots,
33
- findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
34
- ensureRoot, findRoot, findAgent, listAgents, listInstances, servedIdentityLine, servedIdentityOf, listAgentDefs, createAgent as coreCreateAgent,
35
- spawnInstance, spawnInstanceAsync, findModuleCapabilityAgent, capabilityAgentFromDir, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
26
+ LAYERS, OATS_VERSION, manifestOperations,
27
+ capabilityManifests, capabilityTrust, capabilityExecutablePath,
28
+ officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
29
+ findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, explicitInstanceName, findModuleCapabilityAgent, capabilityAgentFromDir, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, planLaunch, redactLaunchCommand, restartInstanceSession,
36
30
  } from "../lib/core.mjs";
37
31
  import {
38
- assertNoSymlinkedParents, writeFileAtomic,
39
- LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage,
40
- classifyPackageValue, parsePackageRequest, executablesDigestAt } from "../lib/packages.mjs";
41
- import { loadLocal, discoverWorkspace, validateWorkspace } from "../lib/workspace.mjs";
32
+ writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages,
33
+ classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
34
+ import { loadLocal, validateWorkspace, validateLocal } from "../lib/workspace.mjs";
35
+ import { parseConfigData } from "../lib/config-data.mjs";
42
36
  import * as remoteModule from "../lib/remote.mjs";
43
- import { createInterface } from "node:readline/promises";
44
37
  import YAML from "yaml";
45
38
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
46
39
  import { spawnSync as spawnSyncProc } from "node:child_process";
@@ -48,25 +41,16 @@ import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describe
48
41
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
49
42
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
50
43
 
51
- import { capturedSelector } from "../lib/captured-selector.mjs";
52
- import { inspectCapturedPiOutcome } from "../lib/captured-pi-host.mjs";
53
- import { readCapturedResolution } from "../lib/captured-resolutions.mjs";
54
- import { oatsError } from "../lib/errors.mjs";
55
- import { canonicalJson } from "../lib/portable-values.mjs";
56
- import { readPortablePreparationRequest } from "../lib/portable-onboarding-request.mjs";
57
- import { portableScope } from "../lib/portable-state.mjs";
58
- import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../lib/captured-operation-process.mjs";
59
- import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
60
44
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
61
45
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
62
46
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
63
- import { readinessOf, policyOf } from "../lib/readiness.mjs";
47
+ import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
64
48
  import { readEvents } from "../lib/instance-events.mjs";
65
49
 
66
50
  const args = process.argv.slice(2);
67
51
  let cmd = args[0];
68
52
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
69
- const KERNEL_COMMANDS = new Set(["prepare", "capture", "capabilities", "create", "doctor", "inspect", "instance", "operation", "package", "readiness", "soul", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "type", "update", "version", "workspace"]);
53
+ const KERNEL_COMMANDS = new Set(["capture", "capabilities", "doctor", "inspect", "instance", "operation", "package", "readiness", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "update", "version", "workspace"]);
70
54
  const flag = (name) => {
71
55
  const i = args.indexOf(`--${name}`);
72
56
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -81,7 +65,7 @@ function valueFlag(name) {
81
65
  return value;
82
66
  }
83
67
  const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
84
- const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
68
+ const cmdFail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
85
69
  /** Resolve the --dir flag with central validation: a value-taking flag given
86
70
  * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
87
71
  * uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
@@ -104,382 +88,14 @@ const JSON_MODE = args.includes("--json");
104
88
  const CLI_BIN = realpathSync(fileURLToPath(import.meta.url));
105
89
  const jsonFail = (code, message, details) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message), ...(details !== undefined ? { details } : {}) } })); process.exit(1); };
106
90
  const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result })); };
107
-
108
- function inspectOnboardingCmd() {
109
- const fail = (code, message) => JSON_MODE ? jsonFail(code, message) : die(message);
110
- const values = new Map();
111
- for (let index = 1; index < args.length; index++) {
112
- if (args[index] === "--json") continue;
113
- const key = args[index];
114
- if (!["--request", "--emit-prepare-request"].includes(key) || values.has(key) || !args[index + 1] || args[index + 1].startsWith("--")) fail("E_BAD_ARGS", "source inspection accepts one --request <absolute-json>, optional --emit-prepare-request <new-absolute-json>, and --json");
115
- values.set(key, args[++index]);
116
- }
117
- try {
118
- const output = values.get("--emit-prepare-request");
119
- let parent;
120
- const checkOutput = () => {
121
- if (!isAbsolute(output) || resolve(output) !== output || output.includes("\0")) throw oatsError("E_BAD_ARGS", "prepare-request output needs a normalized absolute path");
122
- let stat;
123
- try {
124
- stat = lstatSync(dirname(output));
125
- if (!stat.isDirectory() || realpathSync(dirname(output)) !== dirname(output)) throw new Error();
126
- } catch { throw oatsError("E_BAD_ARGS", "prepare-request output parent must be an existing real directory"); }
127
- if (parent && (parent.dev !== stat.dev || parent.ino !== stat.ino)) throw oatsError("selection-changed", "prepare-request output parent changed during inspection");
128
- try { lstatSync(output); }
129
- catch (error) { if (error.code === "ENOENT") return stat; throw oatsError("E_BAD_ARGS", "prepare-request output could not be checked"); }
130
- throw oatsError("E_BAD_ARGS", "prepare-request output already exists; choose a new file (nothing overwritten)");
131
- };
132
- if (output !== undefined) parent = checkOutput();
133
- const input = readPortablePreparationRequest({ file: values.get("--request") });
134
- const { prepareRequest, ...view } = inspectPortableOnboarding(input, { includePrepareRequest: output !== undefined });
135
- let result = view;
136
- if (output !== undefined) {
137
- checkOutput();
138
- try { writeFileSync(output, canonicalJson(prepareRequest) + "\n", { flag: "wx", mode: 0o600 }); }
139
- catch { throw oatsError("E_BAD_ARGS", "prepare-request output could not be created exclusively; no existing file was overwritten"); }
140
- const deployment = view.deployment.deployment.path;
141
- result = { ...view, prepareRequestFile: output, effects: { ...view.effects, requestFileWrite: true,
142
- deploymentWrites: output === deployment || output.startsWith(deployment + sep) } };
143
- }
144
- if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
145
- } catch (error) { fail(error.code || "E_INSPECT_FAILED", error.message); }
146
- }
147
-
148
- function prepareCmd() {
149
- const fail = (code, message, details) => JSON_MODE ? jsonFail(code, message, details) : die(message);
150
- const values = new Map(), allowed = new Set(["request", "dir", "source", "revision", "export", "alias", "workspace", "workspace-revision", "work"]);
151
- for (let index = 1; index < args.length; index++) {
152
- if (args[index] === "--json") continue;
153
- const key = args[index].startsWith("--") ? args[index].slice(2) : "";
154
- if (!allowed.has(key) || values.has(key) || !args[index + 1] || args[index + 1].startsWith("--")) fail("E_BAD_ARGS", "prepare needs unique named source/context arguments; use prepare --help");
155
- values.set(key, args[++index]);
156
- }
157
- try {
158
- let input;
159
- if (values.has("request")) {
160
- // The shared leaf owns request bytes/exclusivity; this router alone owns
161
- // argv and has already refused explicit captured selectors.
162
- input = readPortablePreparationRequest({ file: values.get("request"),
163
- inputFlags: Object.fromEntries([...values].filter(([key]) => key !== "request")) });
164
- } else {
165
- const deployment = values.get("dir"), alias = values.get("alias"), source = values.get("source");
166
- if (!deployment || !isAbsolute(deployment) || !alias) fail("E_BAD_ARGS", "prepare needs --dir <absolute deployment> and --alias <name>");
167
- if (source ? !values.get("revision") || !values.get("export") : !values.get("workspace") || values.has("revision") || values.has("export")) fail("E_BAD_ARGS", "choose a complete source/revision/export reference or a workspace-advertised alias");
168
- if (values.has("workspace-revision") && !values.has("workspace")) fail("E_BAD_ARGS", "--workspace-revision requires --workspace");
169
- const origin = { kind: "operator", document: { kind: "operator", id: "oats-prepare" }, pointer: "/source" };
170
- input = { deployment, source: source ? { source, revision: values.get("revision"), soul: values.get("export"), alias } : alias, origin,
171
- ...(values.has("work") ? { mode: values.get("work") } : {}),
172
- ...(values.has("workspace") ? { workspace: { source: values.get("workspace"), origin: { ...origin, pointer: "/workspace" },
173
- ...(values.has("workspace-revision") ? { revision: values.get("workspace-revision") } : {}) } } : {}) };
174
- }
175
- // Pass the whole request to the one public validator/resolver. Unknown
176
- // fields are refused there, never filtered or filled from ambient state.
177
- const result = prepareCapturedComposition(input);
178
- if (!result.resolution) {
179
- const summary = "preparation is incomplete; no executable resolution was published";
180
- const reasons = (result.problems ?? []).filter(p => p.key !== undefined || (p.slot && p.capability))
181
- .map(p => `[${p.slot && p.capability ? `${p.capability}/${p.slot}` : p.code}] ${p.key === undefined ? "" : `${JSON.stringify(p.key)}: `}${p.message}`);
182
- fail("needs-configuration", JSON_MODE ? summary : [summary, ...reasons].join("\n"), result);
183
- }
184
- if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
185
- } catch (error) { fail(error.code || "E_PREPARE_FAILED", error.message); }
186
- }
187
-
188
- /** Run one provider operation from immutable captured authority. A home is an
189
- * explicit target only: its stored binding must name this exact record. */
190
- function capturedOperation(selector, load, bail) {
191
- if (args[1] !== "run") bail("E_USAGE", "usage: oats operation run <layer>:<name> --deployment <abs> --resolution <id> [--home <abs>] [--arg k=v ...] [--retry-intent <saved-id>] [--json]");
192
- const address = args[2], match = typeof address === "string" ? OPERATION_ADDRESS_RE.exec(address) : null;
193
- if (!match) bail("E_BAD_ARGS", `operation address must be <layer>:<name> with layer one of ${LAYERS.join(", ")} (got ${JSON.stringify(address)})`);
194
- const [, slot, name] = match, given = Object.create(null);
195
- let home, retryExecutionId;
196
- for (let index = 3; index < args.length; index++) {
197
- const token = args[index];
198
- if (token === "--json") continue;
199
- if (token === "--home") {
200
- const value = args[++index];
201
- if (home !== undefined || !value || value.startsWith("--") || !isAbsolute(value)) bail("E_BAD_ARGS", "--home needs one absolute instance home");
202
- home = resolve(value); continue;
203
- }
204
- if (token === "--retry-intent") {
205
- const value = args[++index];
206
- if (retryExecutionId !== undefined || !value || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) bail("E_BAD_ARGS", "--retry-intent requires one saved executionId");
207
- retryExecutionId = value; continue;
208
- }
209
- if (token === "--arg") {
210
- const value = args[++index], eq = value?.indexOf("=") ?? -1;
211
- if (eq < 1) bail("E_BAD_ARGS", "--arg expects name=value");
212
- const key = value.slice(0, eq);
213
- if (Object.hasOwn(given, key)) bail("E_BAD_ARGS", `duplicate operation arg ${JSON.stringify(key)}`);
214
- given[key] = value.slice(eq + 1); continue;
215
- }
216
- bail("E_BAD_ARGS", `unsupported captured operation argument ${JSON.stringify(token)}`);
217
- }
218
- let meta;
219
- if (home) {
220
- const metaFile = join(home, "instance.json");
221
- if (!existsSync(metaFile)) bail("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
222
- try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (error) { bail("E_SESSION_UNKNOWN", `${metaFile}: ${error.message}`); }
223
- if (!meta || typeof meta !== "object" || Array.isArray(meta) || typeof meta.instance !== "string" || !meta.instance) bail("E_SESSION_UNKNOWN", `${metaFile}: invalid instance metadata`);
224
- const binding = meta.executionBinding;
225
- if (!binding) bail("migration-required", `${home} has no captured executionBinding; current configuration was not used`);
226
- if (binding.schemaVersion !== 1 || typeof binding.deployment !== "string" || !isAbsolute(binding.deployment)
227
- || realOrResolved(binding.deployment) !== realOrResolved(selector.deployment)
228
- || binding.resolution?.schemaVersion !== 1 || binding.resolution.id !== selector.resolution.id) {
229
- bail("E_HOME_MISMATCH", `${home} is not bound to captured resolution ${selector.resolution.id} in ${selector.deployment}`);
230
- }
231
- }
232
- // Inspect is static: validate target and arguments before the action load runs
233
- // the provider's mutable readiness check.
234
- const inspected = load({ kind: "inspect" }), providerId = inspected.record.bindings[slot]?.capability;
235
- const provider = providerId ? inspected.manifests.get(providerId) : undefined;
236
- if (!provider) bail("capability-not-selected", `no captured ${slot} provider is selected`);
237
- const operation = manifestOperations(provider).find((entry) => entry.name === name);
238
- if (!operation) bail("operation-not-found", "captured provider does not declare this operation");
239
- if (operation.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
240
- if (operation.context === "scope" && meta) bail("E_BAD_ARGS", `${address} is a scope operation and does not accept --home`);
241
- const declared = new Map(operation.args.map((entry) => [entry.name, entry]));
242
- for (const key of Object.keys(given)) if (!declared.has(key)) bail("E_BAD_ARGS", `${address} takes no arg ${JSON.stringify(key)} (declared: ${[...declared.keys()].join(", ") || "none"})`);
243
- for (const entry of operation.args) if (entry.required && given[entry.name] === undefined) bail("E_BAD_ARGS", `${address} needs --arg ${entry.name}=<value>: ${entry.description || "required"}`);
244
- const action = { kind: "operation", slot, name };
245
- const argFlags = operation.args.flatMap((entry) => given[entry.name] === undefined ? [] : [entry.flag, given[entry.name]]), cwd = home || selector.deployment;
246
- if (operation.kind === "action" && !home) bail("admission-required", "scope mutation has no qualified incarnation/admission path; use an instance-scoped operation");
247
- if (retryExecutionId !== undefined && operation.kind !== "action") bail("E_BAD_ARGS", "read-only operations do not retry mutation intents");
248
- const admission = operation.kind === "action" ? admitCapturedAction({ deployment: selector.deployment, resolution: selector.resolution, home, action, input: { arguments: argFlags },
249
- ...(retryExecutionId !== undefined ? { retryExecutionId } : {}) }) : null;
250
- let settlement;
251
- const admittedBail = (code, message, details) => bail(code, message, { ...details, ...(admission ? { intent: admission.intent } : {}),
252
- ...(settlement ? { settlement, unconfirmed: settlement.state === "unconfirmed" } : {}) });
253
- if (admission?.replayed) {
254
- settlement = { state: "completed", receipt: admission.receipt };
255
- if (!admission.replayable) admittedBail("needs-configuration", "completed operation cannot replay its retained outcome");
256
- finishOperation({ r: { status: 0, stdout: JSON.stringify(admission.receipt) }, bail: admittedBail, address, provider, op: operation, argFlags, cwd, home, meta, intent: admission.intent }); return;
257
- }
258
- let loaded;
259
- try { loaded = load(action, { invocationTarget: meta ? { home, work: join(home, "work"), name: meta.instance, agent: meta.agent } : null,
260
- ...(admission ? { intent: admission.intent, priorReceipt: admission.receipt } : {}) }); }
261
- catch (error) {
262
- if (admission) {
263
- settleCapturedIntent({ deployment: selector.deployment, home, intent: admission.intent, action, state: "blocked", receipt: admission.receipt });
264
- settlement = { state: "blocked", receipt: admission.receipt };
265
- }
266
- admittedBail(error.code || "provider-unavailable", error.message);
267
- }
268
- const { capability, executable } = loaded;
269
- const env = { ...process.env };
270
- for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
271
- Object.assign(env, {
272
- OATS_DEPLOYMENT: selector.deployment, OATS_RESOLUTION: selector.resolution.id,
273
- OATS_CAPABILITY: capability.id, OATS_CAPABILITY_ROOT: capability.manifest._dir,
274
- OATS_SETTINGS: JSON.stringify(capability.settings), OATS_CLI_BIN: CLI_BIN,
275
- OATS_OPERATION: address, OATS_CONTEXT: selector.deployment, OATS_LEVEL: selector.deployment,
276
- OATS_WORKSPACE: selector.deployment,
277
- });
278
- if (meta) Object.assign(env, { OATS_INSTANCE: meta.instance, OATS_INSTANCE_HOME: home, OATS_HOME: home,
279
- PI_AGENT_INSTANCE: meta.instance, PI_AGENT_HOME: home, ...(meta.agent ? { OATS_AGENT: meta.agent } : {}) });
280
- const invocation = loaded.invocation;
281
- let child, cleanupError, started = false;
282
- try {
283
- child = withCapturedInvocationContextFile(invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => {
284
- if (admission) { beginCapturedIntent({ deployment: selector.deployment, home, intent: admission.intent, action }); started = true; }
285
- return runCapturedOperationProcess({ file: executable.file, args: [...executable.args, ...argFlags, "--json"], cwd, env: { ...env, ...contextEnv, ...bindingEnv } });
286
- }));
287
- } catch (error) {
288
- if (!error?.invocationCompleted) {
289
- if (admission) {
290
- settleCapturedIntent({ deployment: selector.deployment, home, intent: admission.intent, action, state: started ? "unconfirmed" : "blocked", receipt: admission.receipt });
291
- settlement = { state: started ? "unconfirmed" : "blocked", receipt: admission.receipt };
292
- }
293
- admittedBail(error.code || "E_OPERATION_RESULT", error.message, { unconfirmed: started });
294
- }
295
- child = error.invocationResult; cleanupError = error;
296
- }
297
- if (admission) {
298
- let envelope; try { envelope = JSON.parse(String(child.stdout || "").trim()); } catch { /* unconfirmed below */ }
299
- const completed = !cleanupError && !child.error && child.status === 0 && envelope?.schemaVersion === 1 && envelope.ok === true;
300
- const state = completed ? "completed" : "unconfirmed", receipt = envelope ?? parseEnvelopeText(String(child.stdout || "")) ?? admission.receipt;
301
- try {
302
- settleCapturedIntent({ deployment: selector.deployment, home, intent: admission.intent, action, state, receipt, replayable: completed });
303
- settlement = { state, receipt };
304
- }
305
- catch (error) { admittedBail("E_OPERATION_RESULT", "operation ran but outcome custody could not be confirmed", { unconfirmed: true, envelope, custody: { code: error.code, message: error.message } }); }
306
- }
307
- finishOperation({ r: child, bail: admittedBail, address, provider: capability.manifest, op: operation, argFlags, cwd, home, meta, cleanupError, settlement, ...(admission ? { intent: admission.intent } : {}) });
308
- }
309
-
310
- /** Fresh explicit captured scaffold + spawn hooks. Placement is supplied by
311
- * the operator; launch and non-directory work remain unsupported. */
312
- function capturedSpawn(selector, load, bail) {
313
- const subject = args[1]; let home; let noLaunch = false;
314
- if (!subject || subject.startsWith("-")) bail("E_BAD_ARGS", "captured spawn needs the retained subject name");
315
- for (let index = 2; index < args.length; index++) {
316
- const token = args[index];
317
- if (token === "--json") continue;
318
- if (token === "--no-launch") { if (noLaunch) bail("E_BAD_ARGS", "duplicate --no-launch"); noLaunch = true; continue; }
319
- if (token === "--home") {
320
- const value = args[++index];
321
- if (home !== undefined || !value || value.startsWith("--") || !isAbsolute(value)) bail("E_BAD_ARGS", "--home needs one absolute new instance home");
322
- home = resolve(value); continue;
323
- }
324
- bail("E_BAD_ARGS", `unsupported captured spawn argument ${JSON.stringify(token)}`);
325
- }
326
- if (!home || !noLaunch) bail("E_BAD_ARGS", "captured spawn currently requires --home <absolute new home> and --no-launch");
327
- const inspected = load({ kind: "inspect" }), expected = inspected.record.subject.kind === "persistent" ? inspected.record.subject.soul.alias : inspected.record.subject.name;
328
- if (subject !== expected) bail("E_HOME_MISMATCH", `captured resolution subject is ${expected}, not ${subject}`);
329
- const scaffold = scaffoldCapturedInstance({ deployment: selector.deployment, resolution: selector.resolution, home, instance: basename(home) });
330
- let activated;
331
- try {
332
- activated = activateCapturedScaffold({ deployment: selector.deployment, resolution: selector.resolution, home,
333
- extraEnv: process.env.OATS_HOME_DIR ? { OATS_HOME_DIR: process.env.OATS_HOME_DIR } : {} });
334
- } catch (error) {
335
- if (error?.home) bail(error.code || "E_SPAWN_FAILED", error.message, { home: error.home, cleanupRequired: true, failures: error.provenance || [],
336
- ...(error.capturedCustody ? { unconfirmed: true, custody: error.capturedCustody } : {}) });
337
- throw error;
338
- }
339
- const result = { ...scaffold, hooksPending: activated.hooksPending, cleanupRequired: activated.cleanupRequired, launchPending: true,
340
- hookIntents: activated.hooks.intents, hookOrder: activated.hooks.order, warnings: activated.hooks.warnings };
341
- if (JSON_MODE) jsonOk(result); else console.log(`Scaffolded ${result.instance} at ${result.home}; ${result.hooksPending ? "captured hook custody requires retry/reconciliation" : "captured hooks complete"}, launch pending`);
342
- }
343
-
344
- /** Native continuation of an already owned captured home. A helper selector is
345
- * an exact edge from the SOURCE record, not a name/current-config resolver. */
346
- function capturedSession(selector, bail) {
347
- const inspecting = args[1] === "inspect";
348
- if (!["start", "restart", "inspect"].includes(args[1])) bail("unsupported-action", "captured session supports start/restart or explicit Pi outcome inspect; no current-context fallback was used");
349
- const values = new Map(), allowed = new Set(inspecting ? ["home", "helper", "native-record"] : ["home", "helper", "request", "retry-intent"]);
350
- for (let index = 2; index < args.length; index++) {
351
- if (args[index] === "--json") continue;
352
- const key = args[index].startsWith("--") ? args[index].slice(2) : "", value = args[index + 1];
353
- if (!allowed.has(key) || values.has(key) || !value || value.startsWith("--")) bail("E_BAD_ARGS", "captured session needs unique named home/helper/request/retry arguments");
354
- values.set(key, value); index++;
355
- }
356
- const home = values.get("home"), retry = values.get("retry-intent");
357
- if (!home || !isAbsolute(home) || resolve(home) !== home || home.includes("\0")) bail("E_BAD_ARGS", "captured session needs a normalized absolute --home");
358
- if (retry !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(retry)) bail("E_BAD_ARGS", "--retry-intent requires one saved executionId");
359
- if (inspecting && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(values.get("native-record") ?? "")) bail("E_BAD_ARGS", "captured Pi outcome inspect requires one explicit --native-record UUID");
360
- const sourceExecutionBinding = { schemaVersion: 1, deployment: portableScope(selector.deployment), resolution: selector.resolution };
361
- // Pure retained-subject check, before request files, provider or native calls.
362
- // Dedicated helper IDs remain valid for scaffolding, not edge-less dispatch.
363
- if (!values.has("helper") && readCapturedResolution(sourceExecutionBinding.deployment, sourceExecutionBinding.resolution).subject.kind === "helper") {
364
- bail("helper-not-selected", "captured helper session needs SOURCE selectors plus --helper EXACT_SOURCE_HELPER_KEY");
365
- }
366
- // Reuse the same bounded strict object-file transport. Preparation and native
367
- // request schemas remain separate; reject unknown fields before projection.
368
- let request = {};
369
- if (values.has("request")) {
370
- const input = readPortablePreparationRequest({ file: values.get("request") });
371
- if (input.schemaVersion !== 1 || Object.keys(input).some(key => !["schemaVersion", "backend", "task", "stopGraceMs"].includes(key))) bail("E_BAD_ARGS", "native request must be version1 with only backend/task/stopGraceMs");
372
- if (Object.hasOwn(input, "backend") && (!input.backend || typeof input.backend !== "object" || Array.isArray(input.backend))) bail("E_BAD_ARGS", "a supplied native backend must be an explicit object, not omission");
373
- const { schemaVersion, ...fields } = input; request = fields;
374
- }
375
- const helperSelection = values.has("helper") ? resolveCapturedHelper({ executionBinding: sourceExecutionBinding, helper: values.get("helper") }) : null;
376
- const executionBinding = helperSelection?.executionBinding ?? sourceExecutionBinding;
377
- try {
378
- if (inspecting) {
379
- const outcome = inspectCapturedPiOutcome(home, { ...executionBinding, nativeRecordId: values.get("native-record") });
380
- const result = { outcome, executionBinding, ...(helperSelection ? { sourceExecutionBinding: helperSelection.sourceExecutionBinding, helper: helperSelection.helper } : {}) };
381
- if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
382
- return;
383
- }
384
- const native = startCapturedInstanceSession(home, { ...request, deployment: executionBinding.deployment, resolution: executionBinding.resolution,
385
- restart: args[1] === "restart", ...(retry !== undefined ? { retryExecutionId: retry } : {}) });
386
- const result = { ...native, executionBinding, ...(helperSelection ? { sourceExecutionBinding: helperSelection.sourceExecutionBinding, helper: helperSelection.helper } : {}) };
387
- if (JSON_MODE) jsonOk(result); else console.log(`${result.replayed ? "Replayed captured dispatch receipt for" : "Dispatched captured native session for"} ${home}`);
388
- } catch (error) {
389
- bail(error.code || "E_SESSION_START_FAILED", error.message, { home: error.home ?? home, executionBinding,
390
- ...(helperSelection ? { sourceExecutionBinding: helperSelection.sourceExecutionBinding, helper: helperSelection.helper } : {}),
391
- ...(error.capturedCustody ? { custody: error.capturedCustody } : {}),
392
- ...(error.nativeCustody ? { unconfirmed: true, nativeCustody: error.nativeCustody } : {}) });
393
- }
394
- }
395
-
396
- /** Exact-selector dispatch enters before any current-context resolver. Its
397
- * child receives the same selector, never an invoking agent's ambient identity. */
398
- function capturedCommand(selector) {
399
- const fail = (code, message, details) => JSON_MODE ? jsonFail(code, message, details) : die(message);
400
- try {
401
- const end = args.indexOf("--"), head = end < 0 ? args : args.slice(0, end);
402
- const permitsHome = cmd === "operation" || cmd === "spawn" || cmd === "session";
403
- const forbiddenContext = permitsHome ? ["--dir", "--server", "--soul", "--agents-root"] : ["--dir", "--home", "--server", "--soul", "--agents-root"];
404
- if (head.some((arg) => forbiddenContext.includes(arg.split("=")[0]))) {
405
- fail("E_BAD_ARGS", "captured selectors cannot be mixed with current-context selectors");
406
- }
407
- if (selector.artifactSet !== undefined) {
408
- if (cmd !== "trust" || !args[1] || args[1].startsWith("-") || args.slice(2).some((arg) => arg !== "--json")) fail("E_BAD_ARGS", "artifact-set selectors support only explicit trust of one capability");
409
- const result = approveAvailableCapability(selector.deployment, selector.artifactSet, args[1], {
410
- kind: "operator", document: { kind: "operator", id: "oats-trust-artifact-set" }, pointer: "/capability",
411
- });
412
- if (JSON_MODE) jsonOk(result); else console.log(`${args[1]}: ${result.status}`);
413
- return;
414
- }
415
- const target = { deployment: selector.deployment, resolution: selector.resolution };
416
- const load = (action, extra = {}) => loadCapturedDispatch({ ...target, action, ...extra });
417
- if (cmd === "inspect") {
418
- let helperKey;
419
- for (let index = 1; index < args.length; index++) {
420
- if (["--json", "--composition"].includes(args[index])) continue;
421
- if (args[index] !== "--helper" || helperKey !== undefined || !args[index + 1] || args[index + 1].startsWith("--")) fail("E_BAD_ARGS", "captured inspect accepts --json, --composition and one --helper <exact-map-key>");
422
- helperKey = args[++index];
423
- }
424
- const helperSelection = helperKey === undefined ? null : resolveCapturedHelper({ executionBinding: { schemaVersion: 1, ...target }, helper: helperKey });
425
- const selected = helperSelection?.executionBinding ?? target;
426
- const loaded = loadCapturedDispatch({ deployment: selected.deployment, resolution: selected.resolution, action: { kind: args.includes("--composition") ? "compose" : "inspect" } });
427
- const result = { resolution: loaded.resolution, capture: loaded.record.capture, nativeSession: capturedNativeSessionAvailability(),
428
- launchSelection: loaded.record.dispatch.launch === null ? null : {
429
- runtime: loaded.record.dispatch.launch.runtime, model: loaded.record.dispatch.launch.model,
430
- },
431
- ...(helperSelection ? { helperSelection } : {}),
432
- capabilities: [...loaded.capabilities.values()].map(({ id, manifest }) => ({ id, version: manifest.version,
433
- approval: loaded.approvals.find((entry) => entry.artifact.capability === id).status })),
434
- helpers: Object.entries(loaded.record.helpers).map(([key, resolution]) => ({ key, resolution })),
435
- hasComposition: !!loaded.record.dispatch.composition,
436
- ...(loaded.composition ? { composition: loaded.composition } : {}) };
437
- if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
438
- return;
439
- }
440
- if (cmd === "trust") {
441
- if (!args[1] || args[1].startsWith("-") || args.slice(2).some((arg) => arg !== "--json")) fail("E_BAD_ARGS", "captured trust needs one capability ID");
442
- load({ kind: "inspect" }); // complete manifest validation before approval
443
- const result = approveCapturedCapability(selector.deployment, selector.resolution, args[1], {
444
- kind: "operator", document: { kind: "operator", id: "oats-trust" }, pointer: "/capability",
445
- });
446
- if (JSON_MODE) jsonOk(result); else console.log(`${args[1]}: ${result.status}`);
447
- return;
448
- }
449
- if (cmd === "operation") { capturedOperation(selector, load, fail); return; }
450
- if (cmd === "spawn") { capturedSpawn(selector, load, fail); return; }
451
- if (cmd === "session") { capturedSession(selector, fail); return; }
452
- if (!cmd || cmd.startsWith("-") || KERNEL_COMMANDS.has(cmd)) fail("unsupported-action", "this kernel command has not yet adopted captured selectors; no current-context fallback was used");
453
- if (!args[1] || args[1] === "--json" || head.some((arg) => HELP_WORDS.has(arg))) {
454
- const loaded = load({ kind: "inspect" });
455
- const matches = [...loaded.manifests.values()].filter((manifest) => manifest.command === cmd);
456
- if (matches.length !== 1) fail("capability-not-selected", "captured command namespace is absent or ambiguous");
457
- const result = { capability: matches[0].capability, commands: Object.keys(matches[0].commands || {}), help: "manifest only; no executable ran" };
458
- if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
459
- return;
460
- }
461
- const loaded = load({ kind: "command", namespace: cmd, name: args[1] });
462
- const env = { ...process.env };
463
- for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
464
- Object.assign(env, { OATS_DEPLOYMENT: selector.deployment, OATS_RESOLUTION: selector.resolution.id,
465
- OATS_CAPABILITY: loaded.capability.id, OATS_CAPABILITY_ROOT: loaded.capability.manifest._dir,
466
- OATS_SETTINGS: JSON.stringify(loaded.capability.settings),
467
- OATS_CLI_BIN: CLI_BIN, OATS_CONTEXT: selector.deployment, OATS_LEVEL: selector.deployment });
468
- const forwarded = args.slice(2); if (forwarded[0] === "--") forwarded.shift();
469
- const child = withCapturedInvocationContextFile(loaded.invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => spawnSync(process.execPath, [loaded.executable.file, ...loaded.executable.args, ...forwarded], {
470
- cwd: selector.deployment, env: { ...env, ...contextEnv, ...bindingEnv }, stdio: "inherit",
471
- })));
472
- if (child.error) fail("E_CAPABILITY_BROKEN", child.error.message);
473
- process.exit(child.status ?? 1);
474
- } catch (error) { fail(error.code || "E_CAPABILITY_BROKEN", error.message); }
475
- }
476
-
477
- /** Level of a directory: laptop (home), repo (.git), else workspace. */
478
- function levelOf(dir) {
479
- const d = resolve(dir);
480
- if (d === homedir()) return "laptop";
481
- if (existsSync(join(d, ".git"))) return "repo";
482
- return "workspace";
91
+ const formatBytes = (n) => n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MiB` : `${(n / 1024 ** 3).toFixed(1)} GiB`;
92
+ /** A retire recovery's copied outputs (untracked/ignored or directory work), named with their size. */
93
+ function preservedOutputLines(recovery) {
94
+ const outputs = recovery?.outputs;
95
+ if (!outputs?.paths?.length) return [];
96
+ const shown = outputs.paths.slice(0, 8).map((p) => `${p.path} (${formatBytes(p.bytes)})`);
97
+ const more = outputs.paths.length > 8 ? `, and ${outputs.paths.length - 8} more` : "";
98
+ return [` copied outputs: ${shown.join(", ")}${more} — ${formatBytes(outputs.bytes)} in total`];
483
99
  }
484
100
 
485
101
  function shortPath(p) {
@@ -493,56 +109,63 @@ function shellQuote(s) {
493
109
  return /^[A-Za-z0-9._/~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
494
110
  }
495
111
 
496
- /** The scaffolded `name:` value — the target directory's basename — held to the
497
- * SAME write refusal as every other value this CLI renders into a config line.
498
- *
499
- * A basename is filesystem input, not a literal: a directory whose name embeds
500
- * a newline turned one scaffolded `name:` line into arbitrary top-level config
501
- * blocks (a live `team:` block smuggled through `oats init`), and a `#`-leading
502
- * basename wrote a value that reads back as an empty map. Refusing names the
503
- * offending basename and writes nothing — the operator renames the directory. */
504
- function scaffoldConfigName(dir) {
505
- return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
506
- }
507
112
 
508
113
  // ---------- doctor ----------
509
114
  /** Doctor must diagnose, not crash: a stale activation of a retired
510
115
  * capability fails config resolution — surface the cleanup instruction
511
116
  * cleanly (text or JSON) instead of an uncaught stack trace. */
512
- function resolveForDoctor(ctx, soulName, { json } = {}) {
513
- try { return resolveOatsConfig(ctx, soulName); }
514
- catch (e) {
515
- // Doctor is THE diagnosis surface: it alone catches the typed fail-closed
516
- // invalid-lock error and continues to render actionable state.
517
- if (e.code === "invalid-lock") {
518
- const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
519
- if (json) { console.log(JSON.stringify({ context: ctx, error: { code: "invalid-lock", message: e.message, provenance: e.provenance || null } }, null, 2)); process.exit(1); }
520
- console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
521
- console.log(`ERROR: ${e.message} [invalid-lock]`);
522
- 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`);
523
- process.exit(0); // doctor DIAGNOSED successfully; the lock is the problem
524
- }
525
- const retiredId = Object.keys(RETIRED_CAPABILITIES).find((id) => String(e.message).includes(`"${id}"`) && String(e.message).includes("retired"));
526
- if (!retiredId) throw e;
527
- if (json) { console.log(JSON.stringify({ schemaVersion: 1, context: ctx, error: e.message, retired: [retiredId] }, null, 2)); process.exit(1); }
528
- die(`${e.message}`);
529
- }
530
- }
531
117
  function operationalKnowledgeNote(composition, soulName) {
532
118
  return composition && !composition.oatsCoreDeclared
533
- ? `soul ${soulName} has no oats.core capability; kernel-shipped operational skills are deprecated` : null;
534
- }
535
- function doctorComposition(ctx, soulName) {
119
+ ? `soul ${soulName} has no oats.core capability (the workspace default); it gets no OATS operating instructions` : null;
120
+ }
121
+ /** `doctor --soul`: the instructions an instance of that soul would carry. The soul
122
+ * is resolved over the workspace remotes exactly as a spawn preview resolves it,
123
+ * the kernel half composed, and the modules materialized into a scratch home
124
+ * OUTSIDE the deployment (removed after), so module injects are part of the text.
125
+ * Nothing in the deployment is written. Block files of module injects are named
126
+ * home-relative (`.oats/modules/<cap>/<inject>`), where an instance carries them. */
127
+ async function doctorComposition(ctx, soulName, ws, bail) {
536
128
  if (!soulName) return undefined;
537
- const root = findRoot(ctx);
538
- const agent = root && findAgent(root, soulName);
539
- if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
540
- return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
129
+ const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
130
+ const deployment = dirname(ws.local.path);
131
+ const root = join(deployment, "agents");
132
+ const remoteOptions = remoteOptionsFromEnv();
133
+ const cleanups = [];
134
+ // A bail exits the process without unwinding: the temporary copies are removed at exit too.
135
+ process.once("exit", () => { for (const c of cleanups) { try { c(); } catch { /* best effort */ } } });
136
+ try {
137
+ const discovery = await discoverOrStandalone(loadLocal(deployment).local, { remoteOptions });
138
+ const prepared = await prepareInstance(deployment, soulName, { remoteOptions, discovery });
139
+ const pv = await previewWorkspaceSoul(prepared, root);
140
+ cleanups.push(pv.cleanup);
141
+ const agent = findAgentAt(root, prepared.soulEntry.name, pv.soulDir);
142
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${soulName}" was fetched but is not readable as a soul`);
143
+ const composition = composeInstanceAgentsMd(pv.soulDir, deployment, agent.name, agent.work || "checkout", agent.kind, prepared);
144
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "oats-doctor-home-")));
145
+ cleanups.push(() => rmSync(scratch, { recursive: true, force: true }));
146
+ const outcome = await materializePrepared({ ...prepared, soulAgentsMd: composition.text, soulDir: pv.soulDir }, scratch);
147
+ const text = readFileSync(join(scratch, "AGENTS.md"), "utf8");
148
+ const known = new Set(composition.blocks.map((b) => b.source));
149
+ const outcomeBlocks = Array.isArray(outcome?.blocks) ? outcome.blocks : [];
150
+ for (const m of text.matchAll(/^<!-- oats:(capability:[^\s]+) src=(.+?) -->$/gm)) {
151
+ const [, source, file] = m;
152
+ if (known.has(source)) continue;
153
+ known.add(source);
154
+ const content = outcomeBlocks.find((b) => b.source === source && b.file === file)?.content ?? (existsSync(file) ? readFileSync(file, "utf8").trim() : "");
155
+ const rel = file.startsWith(scratch + sep) ? file.slice(scratch.length + 1) : file;
156
+ composition.blocks.push({ source, file: rel, content, materialized: true });
157
+ }
158
+ return { ...composition, text };
159
+ } catch (e) {
160
+ if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
161
+ throw e;
162
+ } finally { for (const c of cleanups) { try { c(); } catch { /* best effort: temporary copies only */ } } }
541
163
  }
542
164
 
543
165
  /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
544
166
  * walking up from ctx (oats-local.yaml) and the lock v3 beside it. Doctor never
545
- * goes to the network; membership and discovery are `oats sync` / `oats workspace status`. */
167
+ * goes to the network for this view (only `--soul`, which resolves the soul like a
168
+ * spawn preview); membership and discovery are `oats sync` / `oats workspace status`. */
546
169
  function doctorLockData(ctx) {
547
170
  const out = { local: null, localError: null, lockFile: null, packages: [], lockError: null };
548
171
  let lockDir = ctx;
@@ -551,7 +174,9 @@ function doctorLockData(ctx) {
551
174
  out.local = { path: found.path, workspace: found.local.workspace };
552
175
  lockDir = dirname(found.path);
553
176
  } catch (e) {
554
- if (e?.code === "E_WORKSPACE_SCHEMA") out.localError = { code: e.code, message: e.message };
177
+ // An unreadable oats-local.yaml, or a 0.25 oats-config.yaml inside the deployment
178
+ // (E_CONFIG_BROKEN reason legacy-config): doctor answers it as its typed error.
179
+ if (e?.code === "E_WORKSPACE_SCHEMA" || e?.code === "E_CONFIG_BROKEN") out.localError = { code: e.code, message: e.message, details: e.details };
555
180
  else if (e?.code !== "E_LOCAL_MISSING") throw e;
556
181
  }
557
182
  const file = join(lockDir, LOCK_FILE);
@@ -559,7 +184,7 @@ function doctorLockData(ctx) {
559
184
  out.lockFile = file;
560
185
  try {
561
186
  const lock = readLock(lockDir);
562
- out.packages = Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, path: p.path, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
187
+ out.packages = Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, path: p.path, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities }));
563
188
  } catch (e) {
564
189
  if (e?.code !== "E_LOCK_SCHEMA") throw e;
565
190
  out.lockError = { code: e.code, message: e.message, file: e.details?.file ?? file };
@@ -567,45 +192,6 @@ function doctorLockData(ctx) {
567
192
  return out;
568
193
  }
569
194
 
570
- /** The health of ONE materialized capability, against the rows it was projected
571
- * from. Shared by doctor and list so both name the same states with the same
572
- * codes — and so the `.oats-installation.json` provenance is checked in BOTH,
573
- * not only deep inside trust resolution where it surfaces as a bare "untrusted".
574
- *
575
- * Order matters: a missing artifact cannot be hashed, drifted bytes make an
576
- * approval meaningless (so trust is not ALSO reported), and provenance is only
577
- * worth reading once the bytes are the locked ones. */
578
- /** The lock rows AT one level. Never the merged maps: those resolve each
579
- * identity independently, so an outer scope's capability can be paired with a
580
- * nearer scope's package of the same id — a provider that never exported it. */
581
- const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
582
-
583
- /** A capability has an executable surface when its manifest declares commands,
584
- * hooks or launch environment — the things `oats trust` approves. A
585
- * data-only capability (skills/injects) has none, and trust is not-applicable. */
586
- function hasExecutableSurface(manifest) {
587
- return !!(Object.keys(manifest?.commands || {}).length || Object.keys(manifest?.hooks || {}).length || (manifest?.environment?.length || 0));
588
- }
589
- function capabilityHealth(level, cap, capRow, pkgRow) {
590
- const dir = installedCapabilityDir(level, cap.id);
591
- if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats sync\` to re-materialize it` };
592
- let integrity;
593
- try { integrity = capabilityArtifactIntegrity(dir); }
594
- catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
595
- if (integrity !== cap.integrity) {
596
- 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` };
597
- }
598
- // The artifact's own provenance and the lock must tell the SAME story before
599
- // either is believed. Neither silently wins; the disagreement is the finding.
600
- if (capRow && pkgRow) {
601
- try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
602
- catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
603
- }
604
- const executable = hasExecutableSurface(cap.manifest);
605
- if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — approve it in \`oats sync\`` };
606
- return { status: "ok", code: null, dir, integrity, detail: null };
607
- }
608
-
609
195
  // ---------- inspect: one authoritative answer for GUIs ----------
610
196
  /** Souls, capabilities (installed state and health, separately from
611
197
  * activation), effective layer bindings and declared operations for a
@@ -614,35 +200,19 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
614
200
  * from its roster poll). Nothing here is provider-specific: what a
615
201
  * knowledge provider offers is what its manifest declares. */
616
202
  const INSPECT_TEXT_CAP = 256 * 1024;
617
- function readTextCapped(file) {
618
- let bytes;
619
- try { bytes = readFileSync(file); }
620
- catch (e) { return { file, text: null, sha256: null, truncated: false, error: `${e.code || "EIO"}: ${e.message}` }; }
621
- const truncated = bytes.length > INSPECT_TEXT_CAP;
622
- // The bound is bytes; a cut inside a multi-byte sequence is dropped, never
623
- // rendered as a replacement character.
624
- let text = truncated ? bytes.subarray(0, INSPECT_TEXT_CAP).toString("utf8") : bytes.toString("utf8");
625
- if (truncated && text.endsWith("\uFFFD")) text = text.slice(0, -1);
626
- return { file, text, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, truncated, error: null };
627
- }
628
- /** The canonical agents root a home belongs to, from its path alone:
629
- * <root>/<agent>/instances/<instance>, or <workspace>/local-agents/<agent>/
630
- * instances/<instance> whose canonical root is the sibling agents/. */
631
- function agentsRootOfHome(home) {
632
- const agentDir = dirname(dirname(home));
633
- const base = dirname(agentDir);
634
- return basename(base) === "local-agents" ? join(dirname(base), "agents") : base;
635
- }
203
+ /** The agents root a home belongs to, from its path alone:
204
+ * <root>/<agent>/instances/<instance>. */
205
+ function agentsRootOfHome(home) { return dirname(dirname(dirname(home))); }
636
206
  const SOUL_FIELDS = ["runtime", "model", "yolo", "backend", "description", "launch-config"];
637
207
  const realOrResolved = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
638
- /** Every soul of a scope: persistent and local souls of every agents root in
208
+ /** Every soul of a scope: the persistent souls of every agents root in
639
209
  * scope, plus packaged souls (read-only). One enumeration for inspect and
640
210
  * operation run, so both address souls the same way. */
641
- function scopeSouls(ctx, r, { extraRoots = [] } = {}) {
211
+ function scopeSouls(ctx, { extraRoots = [] } = {}) {
642
212
  // A home's own agents root is always in scope for that home: its recorded
643
213
  // work repository may be another repository entirely (repo overrides), and
644
214
  // the config context resolves there while the soul lives with its owner.
645
- const roots = [...new Set([...(r.team ? teamAgentRoots(r.team.scope) : [findRoot(ctx)]), ...extraRoots].filter(Boolean).map((p) => realOrResolved(resolve(p))))];
215
+ const roots = [...new Set([findRoot(ctx), ...extraRoots].filter(Boolean).map((p) => realOrResolved(resolve(p))))];
646
216
  const souls = [];
647
217
  for (const root of roots) {
648
218
  for (const a of listInstances(root)) {
@@ -652,17 +222,7 @@ function scopeSouls(ctx, r, { extraRoots = [] } = {}) {
652
222
  souls.push(e);
653
223
  }
654
224
  }
655
- const diagnostics = [];
656
- try {
657
- const packaged = listCapabilityAgents(ctx);
658
- diagnostics.push(...(packaged.diagnostics || []));
659
- for (const pa of packaged) {
660
- let soul = {};
661
- try { soul = stripInternalAnnotations(withConfigFile(join(pa.soulDir, "soul.yaml"), () => parseYamlNested(readFileSync(join(pa.soulDir, "soul.yaml"), "utf8")))); } catch { /* reported by name only */ }
662
- souls.push(soulEntry({ ...soul, name: pa.name, description: pa.description ?? soul.description, soulDir: pa.soulDir }, roots[0] || ctx, { capability: pa.capability }));
663
- }
664
- } catch (e) { diagnostics.push({ code: e.code || "E_CAPABILITY_BROKEN", message: e.message }); }
665
- return { roots, souls, diagnostics };
225
+ return { roots, souls, diagnostics: [] };
666
226
  }
667
227
  /** The one soul a name (and optional agents root) addresses; throws with a
668
228
  * code when none or several match. */
@@ -745,215 +305,71 @@ function soulEntry(soul, root, { capability } = {}) {
745
305
  * invoking process's ambient agents-root override must not redirect them
746
306
  * to its own deployment. */
747
307
  function dropAmbientRoot() { delete process.env.PI_AGENTS_ROOT; }
748
- function inspectCmd() { const result = computeInspect(); if (!result) return; if (JSON_MODE) { const { _print, ...data } = result; jsonOk(data); return; } printInspect(result); }
749
- /** The inspect answer as data — shared by `oats inspect` and `oats readiness`
750
- * (K5), so the readiness quartet is derived from the SAME capability,
751
- * activation, trust and soul facts inspect reports, never a second opinion. */
752
- function computeInspect({ onFail } = {}) {
753
- const bail = onFail || ((code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg)));
308
+ async function inspectCmd() {
309
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
310
+ const t = await workspaceTarget(bail, { command: "inspect" });
311
+ if (t) {
312
+ if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
313
+ const doc = inspectDocument(t, { kernel: OATS_VERSION });
314
+ if (JSON_MODE) { jsonOk(doc); return; }
315
+ printWorkspaceInspect(doc); return;
316
+ }
317
+ }
318
+ /** The workspace-model target of inspect / readiness / operation run (lead
319
+ * decision 4): an instance home with materialized modules (`--home`), or a soul
320
+ * of a workspace deployment (`--soul`, resolved as its spawn would be). Anything
321
+ * else is a typed refusal: there is no classic scope answer. */
322
+ async function workspaceTarget(bail, { command, liveTeams = true }) {
754
323
  dropAmbientRoot();
755
- const homeFlag = flag("home");
756
- const home = homeFlag === true ? bail("E_BAD_ARGS", "--home needs an absolute instance home") : homeFlag;
324
+ const homeFlag = flag("home"), soulFlag = flag("soul"), rootFlag = flag("agents-root");
325
+ if (homeFlag === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
326
+ if (soulFlag === true) return bail("E_BAD_ARGS", "--soul needs a soul name");
327
+ if (rootFlag === true) return bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
328
+ const remoteOptions = remoteOptionsFromEnv();
329
+ if (homeFlag) {
330
+ if (!isAbsolute(homeFlag)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
331
+ let meta = null;
332
+ try { meta = JSON.parse(readFileSync(join(homeFlag, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeFlag} is not an OATS instance home (${e.code === "ENOENT" ? "no instance.json" : e.message})`); }
333
+ if (isCapturedHome(meta)) { const e = capturedHomeRefusal(homeFlag, "nothing was read"); return bail(e.code, e.message, e.details); }
334
+ if (!meta || typeof meta.modules !== "object" || meta.modules === null) return bail("E_UNSUPPORTED_MODE", `${homeFlag} is not a workspace-model home (it records no modules): it was spawned by an earlier kernel — re-spawn it from the deployment`);
335
+ if (soulFlag && soulFlag !== meta.agent) return bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${homeFlag} (${meta.agent})`);
336
+ const deployment = dirname(dirname(dirname(dirname(realOrResolved(homeFlag)))));
337
+ // A v2 home lives at <deployment>/agents/<soul>/instances/<name>: its deployment is
338
+ // derived, so it must hold oats-local.yaml EXACTLY there (never found by walking up).
339
+ if (!existsSync(join(deployment, "oats-local.yaml"))) return bail("E_HOME_MISMATCH", `${homeFlag} is not at <deployment>/agents/<soul>/instances/<name>: ${deployment} has no oats-local.yaml`, { home: homeFlag, expected: join(deployment, "oats-local.yaml") });
340
+ if (flag("dir") !== undefined) { let given = null; try { given = dirname(loadLocal(dirFlag()).path); } catch { given = dirFlag(); } if (realOrResolved(given) !== realOrResolved(deployment)) return bail("E_HOME_MISMATCH", `--dir ${dirFlag()} is not the deployment of ${homeFlag} (${deployment}); omit --dir for a home`); }
341
+ if (rootFlag && realOrResolved(rootFlag) !== realOrResolved(join(deployment, "agents"))) return bail("E_HOME_MISMATCH", `--agents-root ${rootFlag} is not the agents root of ${homeFlag}`);
342
+ return homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams });
343
+ }
344
+ try { if (!isWorkspaceContext(dirFlag())) return bail("E_LOCAL_MISSING", `${command} reads a workspace deployment, and none is in reach of ${dirFlag()} (no oats-local.yaml walking up; \`oats onboard\` creates one) — or pass --home <abs> of a workspace instance`); }
345
+ catch (e) { return bail(e?.code || "E_WORKSPACE_SCHEMA", e?.message || String(e), e?.details); }
346
+ if (!soulFlag) return bail("E_BAD_ARGS", `${command} on a workspace deployment needs --soul <name> or --home <abs>${command === "inspect" ? " (the deployment's souls and capabilities: oats souls / oats capabilities)" : ""}`);
347
+ const deployment = dirname(loadLocal(dirFlag()).path);
348
+ if (rootFlag && realOrResolved(rootFlag) !== realOrResolved(join(deployment, "agents"))) return bail("E_SOUL_UNKNOWN", `soul "${soulFlag}" is not at agents root ${rootFlag} (this deployment's is ${join(deployment, "agents")})`);
349
+ try { return await soulTarget(dirFlag(), String(soulFlag), { remoteOptions }); }
350
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
351
+ }
352
+ function printWorkspaceInspect(doc) {
353
+ const s = doc.subject;
354
+ console.log(`oats inspect — ${s.kind === "instance" ? `instance ${s.instance} (soul ${s.soul}) ${shortPath(s.home)}` : `soul ${s.soul} from ${s.repoKey}`}`);
355
+ if (doc.identity) console.log(` identity: ${servedIdentityLine(doc.identity)}`);
356
+ for (const l of LAYERS) console.log(` ${l} capability: ${doc.layers[l].id || "none"}`);
357
+ for (const c of doc.capabilities) console.log(` ${c.id}@${c.version || "?"} ${c.from?.kind === "package" ? `package ${c.from.package}` : c.from?.kind === "member" ? `member ${c.from.repoKey}` : ""}${c.operations.length ? ` ops: ${c.operations.map((o) => `${o.name}${o.available ? "" : "(unavailable)"}`).join(", ")}` : ""}`);
358
+ for (const p of doc.problems) console.log(` ! ${p.code}: ${p.message}`);
359
+ }
360
+
361
+ /** `{ teams, teamsSource }` for a session start of a workspace home: its eligible teams read
362
+ * live (two repository reads), which the launch hook re-checks joined memberships against
363
+ * (teams contract decision 6) — or the spawn record, marked `recorded`, when the read cannot
364
+ * answer. Anything that is not a readable workspace home gets nothing here — the start
365
+ * itself refuses it. */
366
+ async function homeLiveTeams(home) {
757
367
  let meta;
758
- if (home) {
759
- if (!isAbsolute(home)) bail("E_BAD_ARGS", "--home needs an absolute instance home");
760
- const metaFile = join(home, "instance.json");
761
- if (!existsSync(metaFile)) bail("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
762
- try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (e) { bail("E_SESSION_UNKNOWN", `${metaFile}: ${e.message}`); }
763
- }
764
- const real = realOrResolved;
765
- let ctx;
766
- if (meta) {
767
- // The home is the identity and its recorded repository is ALWAYS its
768
- // context (that is what composed it); an explicit --dir is accepted only
769
- // as an alias naming that repository or the workspace of the home's
770
- // agents root, and never replaces the context.
771
- const contexts = homeContexts(home, meta);
772
- if (flag("dir") !== undefined) { const given = dirFlag(); if (!contexts.some((c) => real(c) === real(given))) bail("E_HOME_MISMATCH", `--dir ${given} is not the context of ${home} (${contexts.join(" or ")}); omit --dir for a home`); }
773
- ctx = contexts[0];
774
- } else ctx = dirFlag();
775
- const soulFlag = flag("soul");
776
- if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
777
- if (meta && soulFlag && soulFlag !== meta.agent) bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${home} (${meta.agent})`);
778
- const soulName = soulFlag || meta?.agent || undefined;
779
- let agentsRootFlag = flag("agents-root");
780
- if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
781
- if (meta) {
782
- // The soul is the home's own, under the home's own root; same-named souls
783
- // in other member repositories are ordinary and never ambiguous here.
784
- const homeRoot = agentsRootOfHome(real(home));
785
- if (agentsRootFlag && real(agentsRootFlag) !== real(homeRoot)) bail("E_HOME_MISMATCH", `--agents-root ${agentsRootFlag} is not the agents root of ${home} (${homeRoot})`);
786
- agentsRootFlag = homeRoot;
787
- }
788
- let r;
789
- try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
790
- let chain = configChain(ctx);
791
- const enumerated = scopeSouls(ctx, r, { extraRoots: meta ? [agentsRootOfHome(realOrResolved(home))] : [] });
792
- const roots = enumerated.roots;
793
- let souls = enumerated.souls;
794
- const packagedDiagnostics = enumerated.diagnostics;
795
- let selectedSoul = null;
796
- const requestedContext = ctx;
797
- if (soulName) {
798
- try { selectedSoul = selectSoul(souls, soulName, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
799
- selectedSoul.instructions = readTextCapped(selectedSoul.instructionsFile);
800
- souls = [selectedSoul];
801
- // A soul's effective bindings are its own member's: a team root or
802
- // another member's --dir must not be applied to it. (A home keeps its
803
- // recorded repository as its context; that is what composed it.)
804
- if (!meta) {
805
- const member = memberContextOf(selectedSoul, ctx, flag("dir") !== undefined, bail);
806
- if (member !== realOrResolved(ctx)) {
807
- ctx = member;
808
- try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
809
- chain = configChain(ctx);
810
- }
811
- }
812
- }
813
-
814
- // Capabilities: installed state and health from the package engine (exactly
815
- // what `oats list` reports), owned/path manifests beside them, and the
816
- // ACTIVATION for the selected soul (or global) from the resolver.
817
- const mans = capabilityManifests(manifestSource(meta, home, ctx));
818
- let lockError = null;
819
- const byId = new Map();
820
- try {
821
- const pkgs = listInstalledPackages(ctx), locks = readPackageLocks(ctx);
822
- for (const p of pkgs) {
823
- const rows = levelRows(locks, p.level);
824
- for (const c of p.capabilities) {
825
- const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
826
- byId.set(c.id, {
827
- id: c.id, package: p.package, version: c.version || null, layer: c.manifest?.layer || null, command: c.manifest?.command || null,
828
- origin: "installed", level: p.level, source: p.source || null, commit: p.commit ?? rows.packages[p.package]?.commit ?? null, dir: h.dir,
829
- health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, executableSurface: hasExecutableSurface(c.manifest), integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
830
- });
831
- }
832
- }
833
- } catch (e) { lockError = { code: e.code || "invalid-lock", message: e.message }; }
834
- for (const [id, m] of Object.entries(mans)) {
835
- if (byId.has(id)) continue;
836
- const trust = capabilityTrust(m, ctx);
837
- const executable = hasExecutableSurface(m);
838
- let integrity = trust.integrity || null;
839
- if (!integrity) { try { integrity = capabilityArtifactIntegrity(m._dir); } catch { integrity = null; } }
840
- byId.set(id, {
841
- id, package: m._package || null, version: m.version || null, layer: m.layer || null, command: m.command || null,
842
- origin: String(m._origin || "").split(":")[0] || "unknown", level: String(m._origin || "").split(":").slice(1).join(":") || null, source: null, dir: m._dir,
843
- health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, executableSurface: executable, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
844
- });
845
- }
846
- // What is EFFECTIVE for the answer: a home's captured bindings and settings
847
- // (with the currently acquired manifests and current trust); a soul's or
848
- // scope's current config otherwise. The current config is reported
849
- // separately for a home so a GUI can show both without confusing them.
850
- const snapshotCaps = meta ? (meta.capabilities || []) : null;
851
- const layerIdOf = (rec) => { const m = typeof rec === "string" ? /^([a-z0-9][a-z0-9._-]*)(?:\s|$)/.exec(rec) : null; return m && m[1] !== "none" ? m[1] : null; };
852
- const effectiveLayers = meta
853
- ? Object.fromEntries(LAYERS.map((l) => { const id = layerIdOf(meta.layers?.[l]) || snapshotCaps.find((c) => mans[c.id]?.layer === l)?.id || null; const rec = typeof meta.layers?.[l] === "string" ? meta.layers[l] : null; return [l, { id, level: snapshotCaps.find((c) => c.id === id)?.level || null, provenance: rec, disabled: !id && !!rec && rec.startsWith("none") }]; }))
854
- : Object.fromEntries(LAYERS.map((l) => [l, r.layers[l]
855
- ? { id: r.layers[l].id, level: r.layers[l].level, provenance: r.provenance[l] || null, disabled: false }
856
- : { id: null, level: r.layerDisabled?.[l]?.level || null, provenance: r.provenance[l] || null, disabled: !!r.layerDisabled?.[l] }]));
857
- const effectiveActive = (id) => meta
858
- ? (() => { const c = snapshotCaps.find((x) => x.id === id); return c ? { id, level: c.level || null, provenance: c.provenance || [], settings: c.settings || {} } : undefined; })()
859
- : r.capabilities.find((c) => c.id === id);
860
- const declaredAt = (id) => chain.flatMap((cfg) => configCapabilityEntries(cfg).filter((e) => e.id === id).map((e) => ({ level: cfg._level, slot: e.slot || null, targets: [
861
- ...(e.spec.global !== undefined ? [`global`] : []),
862
- ...Object.keys(e.spec["agent-types"] || {}).map((t) => `type:${t}`),
863
- ...Object.keys(e.spec.souls || {}).map((sn) => `soul:${sn}`),
864
- ] })));
865
- const targetOf = (provenance) => [...provenance].map((p) => p.split(" @ ")[0]).sort((a, b) => (b.startsWith("soul:") ? 2 : b.startsWith("type:") ? 1 : 0) - (a.startsWith("soul:") ? 2 : a.startsWith("type:") ? 1 : 0))[0] || (meta ? "snapshot" : "global");
866
- const capabilities = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)).map((entry) => {
867
- const active = effectiveActive(entry.id);
868
- const m = mans[entry.id];
869
- const missingRequires = (() => { try { return capabilityMissingRequires(entry.id, ctx).map((x) => ({ command: x.command, why: x.why || null, install: x.install || null })); } catch { return []; } })();
870
- const disabledLayer = entry.layer && (meta ? (effectiveLayers[entry.layer]?.disabled ? { level: null } : null) : r.layerDisabled?.[entry.layer]);
871
- const declared = declaredAt(entry.id);
872
- const activation = active
873
- ? { enabled: true, source: meta ? "snapshot" : "config", target: targetOf(active.provenance || []), level: active.level, provenance: active.provenance || [], settings: active.settings || {}, declaredAt: declared }
874
- : { enabled: false, source: meta ? "snapshot" : "config", target: declared.length ? "declared" : "none", level: declared[0]?.level || null, provenance: [], settings: {}, declaredAt: declared, ...(disabledLayer ? { reason: `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` } : {}) };
875
- const operations = manifestOperations(m).map((op) => {
876
- let reason = null;
877
- if (!active) reason = disabledLayer ? `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` : `${entry.id} is not activated for ${meta ? `home ${basename(home)}` : soulName ? `soul ${soulName}` : "this scope"}`;
878
- else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (approve it in oats sync)`;
879
- else if (entry.health.status !== "ok") reason = entry.health.detail || entry.health.status;
880
- else if (missingRequires.length) reason = `${entry.id} requires ${missingRequires.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
881
- else if (op.context === "home" && !home) reason = "needs a running home (--home)";
882
- return { ...op, argv: [entry.command, op.command], available: !reason, reason };
883
- });
884
- return { ...entry, missingRequires, activation, operations };
885
- });
886
- const layers = effectiveLayers;
887
- // For a home, the CURRENT config beside the captured bindings, so a GUI can
888
- // show what future instances would get without mistaking it for the home's.
889
- const currentConfig = meta ? {
890
- layers: Object.fromEntries(LAYERS.map((l) => [l, r.layers[l] ? { id: r.layers[l].id, level: r.layers[l].level, provenance: r.provenance[l] || null, disabled: false } : { id: null, level: r.layerDisabled?.[l]?.level || null, provenance: r.provenance[l] || null, disabled: !!r.layerDisabled?.[l] }])),
891
- activations: r.capabilities.map((c) => ({ id: c.id, target: targetOf(c.provenance), level: c.level, settings: c.settings || {} })),
892
- } : null;
893
- const knowledgeCap = layers.knowledge.id ? capabilities.find((c) => c.id === layers.knowledge.id) : null;
894
- const knowledge = knowledgeCap ? { provider: knowledgeCap.id, version: knowledgeCap.version, operations: knowledgeCap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] };
895
-
896
- let snapshot = null;
897
- if (meta) {
898
- const runtimeById = new Map((meta.capabilityRuntime || []).map((c) => [c.id, c]));
899
- const drift = [];
900
- for (const c of meta.capabilities || []) {
901
- const now = r.capabilities.find((x) => x.id === c.id);
902
- if (!now) { drift.push({ id: c.id, field: "activation", snapshot: true, config: false }); continue; }
903
- if (JSON.stringify(c.settings || {}) !== JSON.stringify(now.settings || {})) drift.push({ id: c.id, field: "settings", snapshot: c.settings || {}, config: now.settings || {} });
904
- const then = runtimeById.get(c.id)?.trust?.integrity, cur = byId.get(c.id)?.health?.integrity;
905
- if (then && cur && then !== cur) drift.push({ id: c.id, field: "integrity", snapshot: then, config: cur });
906
- }
907
- for (const now of r.capabilities) if (!(meta.capabilities || []).some((c) => c.id === now.id)) drift.push({ id: now.id, field: "activation", snapshot: false, config: true });
908
- snapshot = {
909
- home, instance: meta.instance, agent: meta.agent, runtime: meta.runtime || null, model: meta.model ?? null, yolo: meta.yolo ?? null, launched: !!meta.launched, createdAt: meta.createdAt || null,
910
- layers: meta.layers || {}, capabilities: (meta.capabilities || []).map((c) => ({ id: c.id, level: c.level, settings: c.settings || {}, trusted: runtimeById.get(c.id)?.trust?.trusted ?? null })),
911
- instructions: { ...readTextCapped(join(home, "AGENTS.md")), sources: meta.instructions || [] }, drift,
912
- };
913
- }
914
- // K4: the deployment's portable source context and each soul's declared
915
- // requirements joined against the capability inventory in this same payload.
916
- // Readiness is about the soul's declared sources, kept apart from
917
- // launchability (spawn) and adoption (prepare); unobservable = null.
918
- const capabilityById = new Map(capabilities.map((c) => [c.id, c]));
919
- for (const s of souls) {
920
- const required = s.declarations?.requires?.capabilities;
921
- const requirements = required && typeof required === "object" ? Object.entries(required).map(([id, spec]) => {
922
- const cap = capabilityById.get(id) || null;
923
- return { capability: id, source: spec && typeof spec === "object" ? spec.source ?? null : null,
924
- installed: cap ? cap.health?.installed ?? null : false, approved: cap ? cap.health?.trusted ?? null : null,
925
- active: cap ? !!cap.activation?.enabled : null, version: cap?.version ?? null };
926
- }) : null;
927
- s.readiness = { source: s.provenance ? "recorded" : "unrecorded", requirements,
928
- status: requirements === null ? "undeclared" : requirements.every((q) => q.installed === true) ? "sources-installed" : requirements.some((q) => q.installed === false) ? "sources-missing" : "unknown" };
929
- }
930
- const sourceKey = (p) => JSON.stringify([p.source, p.revision, p.path]);
931
- const sourceItems = [...new Map(souls.filter((s) => s.provenance?.source).map((s) => [sourceKey(s.provenance), { ...s.provenance, souls: [] }])).values()];
932
- for (const s of souls) if (s.provenance?.source) sourceItems.find((i) => sourceKey(i) === sourceKey(s.provenance)).souls.push(s.name);
933
- const sources = { soulsApi: 1, kind: sourceItems.length ? "recorded-provenance" : "none-recorded", items: sourceItems,
934
- note: sourceItems.length ? null : "no soul in this scope records a portable source address" };
935
- const result = {
936
- operationsApi: 1, kernel: OATS_VERSION,
937
- scope: { context: ctx, requestedContext: requestedContext === ctx ? null : requestedContext, workspace: roots.length ? workspaceOf(roots[0]) : ctx, team: r.team || null, chain: chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })), agentsRoots: roots },
938
- selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config",
939
- // Decision 27 (K2): the principal this home acts as, from its messaging provider's hook meta.
940
- ...(meta ? { identity: servedIdentityOf(meta) } : {}) },
941
- souls, sources, layers, capabilities, knowledge, snapshot, currentConfig,
942
- problems: [...(lockError ? [lockError] : []), ...packagedDiagnostics.map((d) => ({ code: d.code, message: d.message, capability: d.capability })),
943
- ...(meta ? snapshotCaps.filter((c) => !mans[c.id]).map((c) => ({ code: "captured-capability-missing", message: `${c.id} was active when this home was composed but no manifest for it is acquired now`, capability: c.id })) : [])],
944
- };
945
- result._print = { ctx, selectedSoul, home };
946
- return result;
947
- }
948
- function printInspect(result) {
949
- const { ctx, selectedSoul, home } = result._print; delete result._print;
950
- const { souls, layers, capabilities } = result;
951
- console.log(`oats inspect — ${shortPath(ctx)}${selectedSoul ? ` soul ${selectedSoul.name}` : ""}${home ? ` home ${shortPath(home)}` : ""}`);
952
- if (result.selected?.identity) console.log(` identity: ${servedIdentityLine(result.selected.identity)}`);
953
- for (const s of souls) console.log(` soul ${s.name} [${s.kind}${s.capability ? ` ${s.capability}` : ""}] runtime ${s.runtime}${s.model ? ` model ${s.model}` : ""} work ${s.work}${s.editable.fields.length ? "" : " (read-only)"}`);
954
- for (const l of LAYERS) console.log(` layer ${l}: ${layers[l].id || (layers[l].disabled ? "disabled" : "none")}${layers[l].provenance ? ` (${layers[l].provenance})` : ""}`);
955
- for (const c of capabilities) console.log(` ${c.id}@${c.version || "?"} ${c.health.status}${c.activation.enabled ? ` active:${c.activation.target}` : " inactive"}${c.operations.length ? ` ops: ${c.operations.map((o) => `${o.name}${o.available ? "" : "(unavailable)"}`).join(", ")}` : ""}`);
956
- for (const p of result.problems) console.log(` ! ${p.code}: ${p.message}`);
368
+ try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return {}; }
369
+ if (!isWorkspaceHome(meta)) return {};
370
+ const { liveTeams } = await import("../lib/instance-resolution.mjs");
371
+ const { teams, source } = await liveTeams(home, meta, { remoteOptions: remoteOptionsFromEnv() });
372
+ return Array.isArray(teams) ? { teams, teamsSource: source } : {};
957
373
  }
958
374
 
959
375
  // ---------- operation run: generic invoke through the capability engine ----------
@@ -969,11 +385,11 @@ const OPERATION_ADDRESS_RE = /^(knowledge|messaging|tasks):([a-z][a-z0-9-]*)$/;
969
385
  const reportsRetainedEffectsText = (message) => /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || ""));
970
386
  // Comfortably below the scheduler's 5-minute command bound and any GUI
971
387
  // proxy, so the receipt always reaches the caller before a wrapper gives up.
972
- const OPERATION_TIMEOUT_MS = CAPTURED_OPERATION_TIMEOUT_MS;
973
- function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home, meta, cleanupError, intent, settlement }) {
388
+ const OPERATION_TIMEOUT_MS = 4 * 60 * 1000;
389
+ function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home, meta, cleanupError, intent, settlement, api }) {
974
390
  const stderr = String(r.stderr || "").trim();
975
391
  const timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal) && (!settlement || !r.error));
976
- const base = { operation: address, capability: provider.capability, version: provider.version || null, argv: [provider.command, op.command, ...argFlags], cwd, target: home ? { home, instance: meta.instance } : null };
392
+ const base = { ...(api ? { operationsApi: api } : {}), operation: address, capability: provider.capability, version: provider.version || null, argv: [provider.command, op.command, ...argFlags], cwd, target: home ? { home, instance: meta.instance } : null };
977
393
  // Unconfirmed outcomes (a timeout, no valid receipt, a receipt contradicted
978
394
  // by the exit status) carry what WAS observed in error.details, so a
979
395
  // scheduler can keep the slot as unknown and reconcile by any name the
@@ -1018,58 +434,8 @@ function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home,
1018
434
  else console.log(JSON.stringify(result, null, 2));
1019
435
  if (stderr) console.error(stderr);
1020
436
  }
1021
- function operationCmd() {
1022
- const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1023
- dropAmbientRoot();
1024
- if (args[1] !== "run") bail("E_USAGE", "usage: oats operation run <layer>:<name> (--home <abs> | --soul <name> [--dir <scope>] [--agents-root <abs>]) [--arg k=v ...] [--json]");
1025
- const address = args[2];
1026
- const m0 = typeof address === "string" ? OPERATION_ADDRESS_RE.exec(address) : null;
1027
- if (!m0) bail("E_BAD_ARGS", `operation address must be <layer>:<name> with layer one of ${LAYERS.join(", ")} (got ${JSON.stringify(address)})`);
1028
- const [, layer, opName] = m0;
1029
- const homeFlag = flag("home");
1030
- if (homeFlag === true) bail("E_BAD_ARGS", "--home needs an absolute instance home");
1031
- const home = homeFlag ? resolve(homeFlag) : undefined;
1032
- let meta;
1033
- if (home) {
1034
- if (!isAbsolute(homeFlag)) bail("E_BAD_ARGS", "--home needs an absolute instance home");
1035
- const metaFile = join(home, "instance.json");
1036
- if (!existsSync(metaFile)) bail("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
1037
- try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (e) { bail("E_SESSION_UNKNOWN", `${metaFile}: ${e.message}`); }
1038
- }
1039
- // The home is the identity: an explicit --dir must be one of its own
1040
- // contexts, never a different scope's config applied to it.
1041
- let ctx;
1042
- if (meta) {
1043
- // The recorded repository is always a home's context; --dir is only an
1044
- // alias to validate (the repository or the workspace of the home's root).
1045
- const contexts = homeContexts(home, meta);
1046
- if (flag("dir") !== undefined) { const given = dirFlag(); if (!contexts.some((c) => realOrResolved(c) === realOrResolved(given))) bail("E_HOME_MISMATCH", `--dir ${given} is not the context of ${home} (${contexts.join(" or ")}); omit --dir for a home`); }
1047
- ctx = contexts[0];
1048
- } else ctx = dirFlag();
1049
- const soulFlag = flag("soul");
1050
- if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
1051
- if (meta && soulFlag && soulFlag !== meta.agent) bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${home} (${meta.agent})`);
1052
- const soulName = soulFlag || meta?.agent || undefined;
1053
- let agentsRootFlag = flag("agents-root");
1054
- if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
1055
- if (meta) {
1056
- const homeRoot = agentsRootOfHome(realOrResolved(home));
1057
- if (agentsRootFlag && realOrResolved(agentsRootFlag) !== realOrResolved(homeRoot)) bail("E_HOME_MISMATCH", `--agents-root ${agentsRootFlag} is not the agents root of ${home} (${homeRoot})`);
1058
- agentsRootFlag = homeRoot;
1059
- }
1060
- // The same soul selection as inspect: name plus agents root, refused when
1061
- // ambiguous, never silently the first match.
1062
- let selectedSoul;
1063
- if (soulName) {
1064
- let rSel;
1065
- try { rSel = resolveOatsConfig(ctx, meta ? undefined : soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1066
- try { selectedSoul = selectSoul(scopeSouls(ctx, rSel, { extraRoots: meta ? [agentsRootOfHome(realOrResolved(home))] : [] }).souls, soulName, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
1067
- // The provider and its settings are the selected soul's own member's,
1068
- // never a team root's or another member's (a home keeps its recorded
1069
- // repository as its context).
1070
- if (!meta) ctx = memberContextOf(selectedSoul, ctx, flag("dir") !== undefined, bail);
1071
- }
1072
- // --arg k=v pairs, matched against the operation's declared args below.
437
+ /** --arg k=v pairs of `oats operation run`. */
438
+ function operationArgs(bail) {
1073
439
  const given = Object.create(null);
1074
440
  for (let i = 3; i < args.length; i++) {
1075
441
  if (args[i] !== "--arg") continue;
@@ -1079,210 +445,92 @@ function operationCmd() {
1079
445
  given[kv.slice(0, eq)] = kv.slice(eq + 1);
1080
446
  i++;
1081
447
  }
1082
- // Provider resolution: the snapshot's active capabilities for a home, the
1083
- // config for a soul/scope.
1084
- const mans = capabilityManifests(manifestSource(meta, home, ctx));
1085
- let provider, settings, team, disabled = null;
1086
- if (meta) {
1087
- const ids = (meta.capabilities || []).map((c) => c.id);
1088
- const id = ids.find((cid) => mans[cid]?.layer === layer);
1089
- provider = id ? mans[id] : undefined;
1090
- settings = (meta.capabilities || []).find((c) => c.id === id)?.settings || {};
1091
- team = meta.team || (() => { try { return resolveOatsConfig(ctx).team; } catch { return undefined; } })();
1092
- if (!provider) { const rec = meta.layers?.[layer]; disabled = typeof rec === "string" && rec.startsWith("none") ? rec : null; }
1093
- } else {
1094
- let r;
1095
- try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1096
- provider = r.layers[layer] ? mans[r.layers[layer].id] : undefined;
1097
- settings = r.layers[layer]?.settings || {};
1098
- team = r.team;
1099
- if (!provider && r.layerDisabled?.[layer]) disabled = `none @ ${r.layerDisabled[layer].level}`;
1100
- }
1101
- if (!provider) bail("E_OPERATION_UNAVAILABLE", disabled ? `layer ${layer} is explicitly disabled (${disabled}); no provider can run ${address}` : `no ${layer} provider is active for ${meta ? home : soulName ? `soul ${soulName} in ${ctx}` : ctx}`);
448
+ return given;
449
+ }
450
+ /** `oats operation run` on the workspace model (operationsApi 2): the provider is
451
+ * the module filling <layer> — the home's own copy, or the soul's resolved module
452
+ * fetched into the deployment's module store — with its merged payload as
453
+ * OATS_SETTINGS and the team facts hooks get. */
454
+ async function workspaceOperation(t, { bail, address, layer, opName }) {
455
+ if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
456
+ const name = t.slots[layer];
457
+ const mod = name ? t.modules.find((x) => x.name === name) : null;
458
+ if (!mod?.manifest) return bail("E_OPERATION_UNAVAILABLE", `no ${layer} provider is resolved for ${t.home ? t.home : `soul ${t.soul.name}`}`);
459
+ const provider = { ...mod.manifest, capability: mod.name };
1102
460
  const op = manifestOperations(provider).find((o) => o.name === opName);
1103
- if (!op) bail("E_OPERATION_UNKNOWN", `${provider.capability} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
1104
- const trust = capabilityTrust(provider, ctx);
1105
- if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (approve it in oats sync)`);
1106
- const missingReq = capabilityMissingRequires(provider.capability, ctx);
1107
- if (missingReq.length) bail("E_CAPABILITY_REQUIRES", `${provider.capability} requires ${missingReq.map((m) => `"${m.command}" on PATH${m.why ? ` (${m.why})` : ""}${m.install ? ` [install: ${m.install}]` : ""}`).join(", ")}; ${address} was not run`);
1108
- if (op.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
461
+ if (!op) return bail("E_OPERATION_UNKNOWN", `${mod.name} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
462
+ const missingReq = manifestMissingRequires(provider);
463
+ if (missingReq.length) return bail("E_CAPABILITY_REQUIRES", `${mod.name} requires ${missingReq.map((m) => `"${m.command}" on PATH${m.why ? ` (${m.why})` : ""}${m.install ? ` [install: ${m.install}]` : ""}`).join(", ")}; ${address} was not run`);
464
+ if (op.context === "home" && !t.home) return bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
465
+ const given = operationArgs(bail);
1109
466
  const declared = new Map(op.args.map((a) => [a.name, a]));
1110
- for (const name of Object.keys(given)) if (!declared.has(name)) bail("E_BAD_ARGS", `${address} takes no arg ${JSON.stringify(name)} (declared: ${[...declared.keys()].join(", ") || "none"})`);
1111
- for (const a of op.args) if (a.required && given[a.name] === undefined) bail("E_BAD_ARGS", `${address} needs --arg ${a.name}=<value>: ${a.description || "required"}`);
467
+ for (const n of Object.keys(given)) if (!declared.has(n)) return bail("E_BAD_ARGS", `${address} takes no arg ${JSON.stringify(n)} (declared: ${[...declared.keys()].join(", ") || "none"})`);
468
+ for (const a of op.args) if (a.required && given[a.name] === undefined) return bail("E_BAD_ARGS", `${address} needs --arg ${a.name}=<value>: ${a.description || "required"}`);
1112
469
  const argFlags = op.args.flatMap((a) => (given[a.name] === undefined ? [] : [a.flag, given[a.name]]));
1113
- const spec = provider.commands[op.command];
1114
- if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `${provider.capability}: command ${op.command} is not a non-empty string`);
470
+ const spec = provider.commands?.[op.command];
471
+ if (typeof spec !== "string" || !spec.trim()) return bail("E_CAPABILITY_BROKEN", `${mod.name}: command ${op.command} is not a non-empty string`);
472
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
473
+ const { layerProvider } = await import("../lib/instance-inspect.mjs");
474
+ let lp;
475
+ try { lp = await layerProvider(t, layer, { catalog, remoteOptions: remoteOptionsFromEnv() }); } catch (e) { return bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
1115
476
  const [script, ...rest] = spec.trim().split(/\s+/);
1116
- let abs;
1117
- try { abs = capabilityExecutablePath(provider, script); } catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
1118
- if (!abs) bail("E_CAPABILITY_BROKEN", `${provider.capability} ${op.command}: script not found (${join(provider._dir, script)})`);
1119
- const cwd = op.context === "home" ? home : ctx;
1120
- // The provider sees exactly the selected target: identity and context
1121
- // variables are SET for it (a home, or a soul in a scope) and every
1122
- // ambient one from the invoking process is removed, so a coordinator
1123
- // running this for another home never steers the provider to its own.
1124
- const env = { ...process.env };
1125
- for (const k of ["OATS_EVENT", "OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_CONTEXT", "OATS_ROOT", "OATS_WORKSPACE", "OATS_LEVEL", "OATS_META", "OATS_KIND", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]) delete env[k];
1126
- const targetRoot = meta ? agentsRootOfHome(realOrResolved(home)) : selectedSoul?.agentsRoot;
1127
- const soulDir = meta ? join(dirname(dirname(realOrResolved(home))), "soul") : selectedSoul ? dirname(selectedSoul.soulFile) : undefined;
1128
- Object.assign(env, {
1129
- OATS_CAPABILITY: provider.capability, OATS_SETTINGS: JSON.stringify(settings || {}), OATS_CLI_BIN: CLI_BIN, OATS_OPERATION: address,
1130
- OATS_CONTEXT: ctx, OATS_WORKSPACE: targetRoot ? workspaceOf(targetRoot) : workspaceOf(findRoot(ctx) || ctx),
1131
- OATS_TEAM_NAME: team?.name || "", OATS_TEAM_ID: team?.id || "", OATS_TEAM_SCOPE: team?.scope || "",
1132
- ...(targetRoot ? { OATS_ROOT: targetRoot, PI_AGENTS_ROOT: targetRoot } : {}),
1133
- ...(soulName ? { OATS_AGENT: soulName } : {}), ...(soulDir ? { OATS_SOUL: soulDir } : {}),
1134
- });
1135
- if (op.context === "home") Object.assign(env, { OATS_INSTANCE: meta.instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, PI_AGENT_INSTANCE: meta.instance, PI_AGENT_HOME: home });
477
+ const abs = lp?.executable(script);
478
+ if (!abs) return bail("E_CAPABILITY_BROKEN", `${mod.name} ${op.command}: script not found (${script})`);
479
+ const settings = lp.settings;
480
+ const cwd = op.context === "home" ? t.home : t.deployment;
481
+ const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
482
+ if (op.context === "home") Object.assign(env, { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home, OATS_HOME: t.home, PI_AGENT_INSTANCE: t.meta.instance, PI_AGENT_HOME: t.home });
483
+ else for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
1136
484
  const r = spawnSync("node", [abs, ...rest, ...argFlags, "--json"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: OPERATION_TIMEOUT_MS, killSignal: "SIGTERM" });
1137
- finishOperation({ r, bail, address, provider, op, argFlags, cwd, home, meta });
485
+ finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
1138
486
  }
1139
-
1140
- // ---------- soul set: runtime defaults and instructions of an editable soul ----------
1141
- /** Rewrites only the given soul.yaml fields, preserving every other line
1142
- * (unknown keys, comments, order), and replaces AGENTS.md when asked.
1143
- * Packaged souls are read-only (their source is the package). */
1144
- async function soulCmd() {
1145
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
487
+ async function operationCmd() {
488
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1146
489
  dropAmbientRoot();
1147
- if (args[1] !== "set") bail("E_USAGE", "usage: oats soul set <name> [--dir <scope>] [--agents-root <abs>] [--runtime pi|claude|codex] [--model <m> | --no-model] [--yolo | --no-yolo] [--backend tmux|herdr] [--description <d> | --no-description] [--instructions-file <path> | --instructions-stdin] [--json]");
1148
- const name = args[2];
1149
- if (!name || name.startsWith("--")) bail("E_BAD_ARGS", "soul set needs a soul name");
1150
- const ctx = dirFlag();
1151
- const agentsRootFlag = flag("agents-root");
1152
- if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
1153
- let r;
1154
- try { r = resolveOatsConfig(ctx); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1155
- let soul;
1156
- try { soul = selectSoul(scopeSouls(ctx, r).souls, name, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
1157
- if (!soul.editable.fields.length) bail("E_SOUL_READONLY", `${name} is a ${soul.kind} soul: ${soul.editable.reason}`);
1158
- // Field changes, validated before anything is written.
1159
- const changes = {};
1160
- const has = (f) => args.includes(`--${f}`);
1161
- const val = (f) => { const v = flag(f); if (v === true) bail("E_BAD_ARGS", `--${f} needs a value`); return v; };
1162
- if (has("runtime")) { const v = val("runtime"); if (!["pi", "claude", "codex"].includes(v)) bail("E_BAD_ARGS", "--runtime must be pi, claude or codex"); changes.runtime = v; }
1163
- if (has("model") && has("no-model")) bail("E_BAD_ARGS", "choose --model <m> or --no-model, not both");
1164
- if (has("model")) { const v = val("model"); if (!v.trim()) bail("E_BAD_ARGS", "--model needs a model id (use --no-model to clear)"); changes.model = assertSafeConfigValue(v, "--model"); }
1165
- if (has("no-model")) changes.model = null;
1166
- if (has("yolo") && has("no-yolo")) bail("E_BAD_ARGS", "choose --yolo or --no-yolo, not both");
1167
- if (has("yolo")) changes.yolo = true;
1168
- if (has("no-yolo")) changes.yolo = false;
1169
- if (has("backend")) { const v = val("backend"); if (!["tmux", "herdr"].includes(v)) bail("E_BAD_ARGS", "--backend must be tmux or herdr"); changes.backend = v; }
1170
- if (has("launch-config") && has("no-launch-config")) bail("E_BAD_ARGS", "choose --launch-config <name> or --no-launch-config, not both");
1171
- if (has("launch-config")) { const v = val("launch-config"); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v)) bail("E_BAD_ARGS", "--launch-config needs a configuration name (letters, digits, dot, underscore, dash)"); changes["launch-config"] = v; }
1172
- if (has("no-launch-config")) changes["launch-config"] = null;
1173
- if (has("description") && has("no-description")) bail("E_BAD_ARGS", "choose --description <d> or --no-description, not both");
1174
- if (has("description")) changes.description = assertSafeConfigValue(val("description"), "--description");
1175
- if (has("no-description")) changes.description = null;
1176
- let instructions;
1177
- if (has("instructions-file") && has("instructions-stdin")) bail("E_BAD_ARGS", "choose --instructions-file or --instructions-stdin, not both");
1178
- if (has("instructions-file")) {
1179
- const file = val("instructions-file");
1180
- let bytes;
1181
- try { bytes = readFileSync(file); } catch (e) { bail("E_BAD_ARGS", `--instructions-file ${file}: ${e.message}`); }
1182
- if (bytes.includes(0)) bail("E_BAD_ARGS", "--instructions-file must be text without NUL bytes");
1183
- if (bytes.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", `--instructions-file is ${bytes.length} bytes; the bound is ${INSPECT_TEXT_CAP} (what inspect can answer whole)`);
1184
- instructions = bytes;
1185
- }
1186
- if (has("instructions-stdin")) {
1187
- // The routed form: bytes arrive on stdin (the ssh transport), bounded
1188
- // while reading, exactly like session receive.
1189
- if (process.stdin.isTTY) bail("E_BAD_ARGS", "--instructions-stdin reads the instructions from stdin");
1190
- let bytes;
1191
- try { bytes = await readStreamBounded(process.stdin, INSPECT_TEXT_CAP); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
1192
- if (bytes.includes(0)) bail("E_BAD_ARGS", "instructions must be text without NUL bytes");
1193
- // An empty completed stream is a deliberate replacement with nothing,
1194
- // exactly like an empty --instructions-file: the option itself states
1195
- // the intent, and a TTY was refused above.
1196
- instructions = bytes;
1197
- }
1198
- if (!Object.keys(changes).length && !instructions) bail("E_BAD_ARGS", "nothing to set: pass at least one of --runtime, --model/--no-model, --yolo/--no-yolo, --backend, --launch-config/--no-launch-config, --description/--no-description, --instructions-file");
1199
- for (const f of Object.keys(changes)) if (!soul.editable.fields.includes(f)) bail("E_BAD_ARGS", `${f} is not an editable field of ${name}`);
1200
- const before = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, backend: soul.backend, description: soul.description, launchConfig: soul.launchConfig };
1201
- // soul.yaml: replace or append `key: value` lines in place; a cleared
1202
- // field's line is removed; nothing else in the file moves.
1203
- let yamlText = "";
1204
- try { yamlText = readFileSync(soul.soulFile, "utf8"); } catch (e) { bail("E_SOUL_UNKNOWN", `${soul.soulFile}: ${e.message}`); }
1205
- const lines = yamlText.replace(/\n*$/, "").split("\n");
1206
- for (const [key, value] of Object.entries(changes)) {
1207
- const idx = lines.findIndex((l) => new RegExp(`^${key}:\\s`).test(l) || l === `${key}:`);
1208
- if (value === null) { if (idx >= 0) lines.splice(idx, 1); continue; }
1209
- const line = `${key}: ${value}`;
1210
- if (idx >= 0) lines[idx] = line; else lines.push(line);
1211
- }
1212
- const receipt = { soul: name, kind: soul.kind, agentsRoot: soul.agentsRoot, file: soul.soulFile, instructionsFile: soul.instructionsFile, changed: Object.keys(changes), before, instructions: null };
1213
- if (Object.keys(changes).length) writeFileAtomic(soul.soulFile, lines.join("\n") + "\n");
1214
- if (instructions) {
1215
- const prev = (() => { try { return createHash("sha256").update(readFileSync(soul.instructionsFile)).digest("hex"); } catch { return null; } })();
1216
- writeFileAtomic(soul.instructionsFile, instructions);
1217
- receipt.instructions = { before: prev, after: createHash("sha256").update(instructions).digest("hex"), bytes: instructions.length };
1218
- }
1219
- let after;
1220
- try { after = selectSoul(scopeSouls(ctx, r).souls, name, soul.agentsRoot, ctx); } catch { after = soul; }
1221
- receipt.after = { runtime: after.runtime, model: after.model, yolo: after.yolo, backend: after.backend, description: after.description };
1222
- if (JSON_MODE) { jsonOk(receipt); return; }
1223
- console.log(`Updated soul ${name} (${shortPath(soul.soulFile)})${instructions ? ` and its instructions (${shortPath(soul.instructionsFile)})` : ""}: ${Object.keys(changes).map((k) => `${k}=${changes[k] === null ? "(cleared)" : changes[k]}`).join(", ") || "instructions only"}`);
1224
- console.log("Future instances use these defaults; existing homes keep what they were composed with.");
490
+ if (args[1] !== "run") bail("E_USAGE", "usage: oats operation run <layer>:<name> (--home <abs> | --soul <name> [--dir <scope>] [--agents-root <abs>]) [--arg k=v ...] [--json]");
491
+ const address = args[2];
492
+ const m0 = typeof address === "string" ? OPERATION_ADDRESS_RE.exec(address) : null;
493
+ if (!m0) bail("E_BAD_ARGS", `operation address must be <layer>:<name> with layer one of ${LAYERS.join(", ")} (got ${JSON.stringify(address)})`);
494
+ const [, layer, opName] = m0;
495
+ // Only the messaging provider's operations act on the teams: others get the record, no remote read.
496
+ const target = await workspaceTarget(bail, { command: "operation run", liveTeams: layer === "messaging" });
497
+ return workspaceOperation(target, { bail, address, layer, opName });
1225
498
  }
1226
499
 
1227
- function doctorJson(dir) {
1228
- const ctx = resolve(dir || process.cwd());
1229
- const soulName = flag("soul");
500
+
501
+ /** Doctor answers on a workspace deployment only (lead decision c3-6): the
502
+ * deployment found walking up from the given directory (positional or --dir),
503
+ * else E_LOCAL_MISSING; an unreadable oats-local.yaml is its own error. */
504
+ function doctorDeployment(dir) {
505
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
506
+ const ctx = resolve(dir || dirFlag());
1230
507
  const ws = doctorLockData(ctx);
1231
- // A v2 deployment (oats-local.yaml found walking up) has NO config chain, layers,
1232
- // acquired packages or installed tier: those keys are omitted, not emitted empty.
1233
- if (ws.local) { console.log(JSON.stringify(doctorWorkspaceJson(ctx, soulName, ws), null, 2)); return; }
1234
- const r = resolveForDoctor(ctx, soulName, { json: true });
1235
- const mans = capabilityManifests(ctx);
1236
- const composition = doctorComposition(ctx, soulName);
1237
- const chain = configChain(ctx);
1238
- const oasScopes = detectOasScopes(ctx);
1239
- console.log(JSON.stringify({
1240
- schemaVersion: 1,
1241
- context: ctx,
1242
- team: r.team || null,
1243
- chain: r.chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })),
1244
- oasScopes,
1245
- oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
1246
- layers: Object.fromEntries(LAYERS.map((l) => [l, r.layers[l] ? {
1247
- integration: r.layers[l].id, level: r.layers[l].level, inject: r.layers[l].inject,
1248
- skills: [...(Array.isArray(r.layers[l].skills) ? r.layers[l].skills : (r.layers[l].skills ? [r.layers[l].skills] : []))],
1249
- hooks: Object.keys(r.layers[l].hooks || {}), missingRequires: r.layers[l].missingRequires,
1250
- provenance: r.provenance[l],
1251
- } : { provenance: r.provenance[l] || null }])),
1252
- kernelInjection: composition?.resolved.kernelInjection ?? r.kernelInjection,
1253
- information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1254
- injects: r.injects,
1255
- 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 })),
1256
- 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 }])),
1257
- retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); } catch { return []; } })()
1258
- .filter(([id]) => retiredCapabilityReason(id))
1259
- .map(([id, lock]) => ({ id, file: lock._file, reason: retiredCapabilityReason(id) })),
1260
- retiredArtifacts: Object.entries(mans)
1261
- .filter(([id]) => retiredCapabilityReason(id))
1262
- .map(([id, m]) => ({ id, dir: m._dir, origin: m._origin, reason: retiredCapabilityReason(id) })),
1263
- // Workspace model v2 (offline view): the lock (v3) and the deployment
1264
- // declaration. Human and JSON doctor derive from ONE computation; a
1265
- // fail-closed lock read is diagnosed via lockError, never consumed as data.
1266
- workspace: ws.local ? { file: ws.local.path, ref: ws.local.workspace } : null,
1267
- workspaceError: ws.localError,
1268
- lockFile: ws.lockFile,
1269
- packages: ws.packages,
1270
- lockError: ws.lockError,
1271
- composedInstructions: composition?.text,
1272
- instructionBlocks: composition?.blocks,
1273
- }, null, 2));
508
+ if (ws.localError) bail(ws.localError.code, ws.localError.message, ws.localError.details);
509
+ if (!ws.local) bail("E_LOCAL_MISSING", `oats doctor reads a workspace deployment, and none is in reach of ${ctx} (no oats-local.yaml walking up; \`oats onboard\` creates one)`, { dir: ctx });
510
+ return { ctx, ws };
511
+ }
512
+ async function doctorJson(dir) {
513
+ const { ctx, ws } = doctorDeployment(dir);
514
+ console.log(JSON.stringify(await doctorWorkspaceJson(ctx, flag("soul"), ws), null, 2));
1274
515
  }
1275
516
 
1276
517
  /** The v2 doctor payload: the deployment declaration + lock (offline) and, with
1277
518
  * --soul, the composed instructions. No v1 keys (chain/layers/acquired/injects…). */
1278
- function doctorWorkspaceJson(ctx, soulName, ws) {
1279
- const composition = doctorComposition(ctx, soulName);
519
+ /** The problems doctor and status share for a v2 deployment: what OATS 0.25 left
520
+ * under local-agents/ (named, never read), and the captured homes under the agents root. */
521
+ function legacyLayoutProblems(root) {
522
+ return [legacyLocalAgents(root), legacyCapturedHomes(root)].filter(Boolean);
523
+ }
524
+ async function doctorWorkspaceJson(ctx, soulName, ws) {
525
+ const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
526
+ const problems = legacyLayoutProblems(join(dirname(ws.local.path), "agents"));
1280
527
  return {
1281
528
  schemaVersion: 1, workspaceApi: 2, context: ctx,
1282
529
  workspace: { file: ws.local.path, ref: ws.local.workspace },
1283
530
  workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
1284
531
  information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1285
532
  composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
533
+ ...(problems.length ? { problems } : {}),
1286
534
  };
1287
535
  }
1288
536
  /** Kernel/bridge version skew (published in lockstep from one tag). */
@@ -1295,142 +543,26 @@ function doctorVersionSkew() {
1295
543
  /** The "Workspace (v2, offline view)" + "Locked packages" sections, shared by both doctor shapes. */
1296
544
  function printDoctorWorkspace(ws) {
1297
545
  console.log("\nWorkspace (v2, offline view):");
1298
- if (ws.local) console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
1299
- else if (ws.localError) console.log(` ERROR: ${ws.localError.message} [${ws.localError.code}]`);
1300
- else console.log(" (no oats-local.yaml found walking up — this scope realizes no v2 workspace; run `oats sync` from one that does)");
546
+ console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
1301
547
  console.log("\nLocked packages (oats-lock.json v3):");
1302
548
  if (ws.lockError) {
1303
549
  console.log(` ERROR: ${ws.lockError.message} [${ws.lockError.code}]`);
1304
550
  if (ws.lockError.file) console.log(` the lock is never auto-repaired; delete ${shortPath(ws.lockError.file)} and run \`oats sync\``);
1305
551
  } else if (!ws.packages.length) console.log(ws.lockFile ? " (none)" : " (no lock yet — run `oats sync`)");
1306
552
  for (const p of ws.packages) {
1307
- console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.approved ? `approved ${p.approved.at}` : "APPROVAL NEEDED (oats sync)"}`);
553
+ console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.integrity}`);
1308
554
  if (p.capabilities.length) console.log(` capabilities: ${p.capabilities.join(", ")}`);
1309
555
  }
1310
556
  console.log(" membership, discovery and drift need the remotes: `oats workspace status`, `oats sync`.");
1311
557
  }
1312
- function doctor(dir) {
1313
- const ctx = resolve(dir || process.cwd());
558
+ async function doctor(dir) {
559
+ const { ctx, ws } = doctorDeployment(dir);
1314
560
  const soulName = flag("soul");
1315
- const ws = doctorLockData(ctx);
1316
561
  console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1317
562
  doctorVersionSkew();
1318
- if (ws.local) {
1319
- // A v2 deployment: nothing is installed and there is no config chain — the v1
1320
- // sections (Config chain / Layers / Kernel injection / Acquired packages / lock
1321
- // warnings) would describe a tier this deployment does not have.
1322
- const composition = doctorComposition(ctx, soulName);
1323
- printDoctorWorkspace(ws);
1324
- if (soulName) {
1325
- const information = operationalKnowledgeNote(composition, soulName);
1326
- if (information) console.log(`\nINFO: ${information}`);
1327
- console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
1328
- } else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
1329
- return;
1330
- }
1331
- const chain = configChain(ctx);
1332
- const r = resolveForDoctor(ctx, soulName);
1333
- const composition = doctorComposition(ctx, soulName);
1334
-
1335
- console.log("Config chain (closest first):");
1336
- if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
1337
- for (const c of chain) {
1338
- console.log(` ${shortPath(c._file)} [${levelOf(c._level)}]`);
1339
- }
1340
-
1341
- // An empty-looking chain over oas-* files is not an empty scope: it is a
1342
- // pre-rename OAS deployment this kernel cannot read (aweb-abfy.1).
1343
- const oasScopes = detectOasScopes(ctx);
1344
- if (oasScopes.length) {
1345
- console.log("");
1346
- for (const f of oasScopes) console.log(`UN-MIGRATED OAS SCOPE: ${shortPath(f.dir)} (${f.files.join(", ")})`);
1347
- console.log(` ${OAS_SCOPE_REMEDY}`);
1348
- }
1349
-
1350
- if (r.team) console.log(`\nTeam: ${r.team.name}${r.team.id ? ` (id: ${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]`);
1351
-
1352
- console.log("\nLayers:");
1353
- for (const layer of LAYERS) {
1354
- const l = r.layers[layer];
1355
- const prov = r.provenance[layer];
1356
- if (!prov) { console.log(` ${layer.padEnd(10)} (unresolved — no declaration in chain)`); continue; }
1357
- if (!l) { console.log(` ${layer.padEnd(10)} none [${prov}]`); continue; }
1358
- console.log(` ${layer.padEnd(10)} ${l.id} [${prov}]`);
1359
- if (l.inject) console.log(` inject: ${shortPath(l.inject)}`);
1360
- const skills = Array.isArray(l.skills) ? l.skills : (l.skills ? [l.skills] : []);
1361
- if (skills.length) console.log(` skills: ${skills.map(shortPath).join(", ")}`);
1362
- const hooks = Object.keys(l.hooks || {});
1363
- if (hooks.length) console.log(` hooks: ${hooks.join(", ")}`);
1364
- for (const miss of l.missingRequires || []) {
1365
- console.log(` MISSING REQUIREMENT: ${miss.command} — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
1366
- }
1367
- }
1368
-
1369
- console.log("\nKernel injection:");
1370
- const kernelInjection = composition?.resolved.kernelInjection ?? r.kernelInjection;
1371
- console.log(` oats: ${kernelInjection?.inject ? shortPath(kernelInjection.inject) : "none"} [${kernelInjection?.provenance || "default"}]`);
1372
-
1373
- console.log("\nUnconditional injections (outermost→innermost):");
1374
- if (r.injects.length === 0) console.log(" (none)");
1375
- for (const inj of r.injects) console.log(` ${inj.source}: ${shortPath(inj.file)}`);
1376
-
1377
- for (const mode of WORK_MODES) {
1378
- const wm = resolveWorkMode(ctx, mode);
1379
- console.log(`\nWork mode ${mode}: inject ${wm.inject ? shortPath(wm.inject) : "none"}${wm.setup ? `, setup ${shortPath(wm.setup)}` : ""}`);
1380
- }
1381
-
1382
- console.log("\nActive capabilities:");
1383
- if (!r.capabilities.length) console.log(" (none)");
1384
- for (const cap of r.capabilities) {
1385
- console.log(` ${cap.id}${cap.layer ? ` layer: ${cap.layer}` : ""} [${cap.provenance.join(" + ")}]`);
1386
- console.log(` trust: ${cap.trust.trusted ? "approved" : `BLOCKED (${cap.trust.reason})`}`);
1387
- if (cap.inject) console.log(` inject: ${shortPath(cap.inject)}`);
1388
- if (cap.skills.length) console.log(` skills: ${cap.skills.map(shortPath).join(", ")}`);
1389
- }
1390
- console.log("\nAcquired capability packages:");
1391
- for (const [name, m] of Object.entries(capabilityManifests(ctx))) {
1392
- const missing = capabilityMissingRequires(name, ctx);
1393
- console.log(` ${name.padEnd(16)} layer: ${(m.layer || "additive").padEnd(10)} origin: ${m._origin}${missing.length ? ` (missing: ${missing.map((x) => x.command).join(", ")})` : ""}`);
1394
- const retiredReason = retiredCapabilityReason(name);
1395
- if (retiredReason) {
1396
- const installed = String(m._origin).startsWith("installed:");
1397
- 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)`}`);
1398
- }
1399
- }
1400
- // readCapabilityLocks fails closed on invalid legacy entries — doctor is the
1401
- // diagnosis surface, so catch the typed error and render it (never using the data).
1402
- let locks = {};
1403
- try { locks = readCapabilityLocks(ctx); }
1404
- catch (e) {
1405
- if (e.code !== "invalid-lock") throw e;
1406
- const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
1407
- console.log(` ERROR: ${e.message} [invalid-lock]`);
1408
- 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`);
1409
- }
1410
- const mans = capabilityManifests(ctx);
1411
- for (const [id, lock] of Object.entries(locks)) {
1412
- const retiredReason = retiredCapabilityReason(id);
1413
- if (retiredReason) { console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but ${retiredReason}`); continue; }
1414
- if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats sync\``);
1415
- }
1416
- for (const [id, m] of Object.entries(mans)) {
1417
- if (!String(m._origin).startsWith("installed:")) continue;
1418
- // SCOPE-EXACT on the v2 side. `m._capabilityLock` is the row from the
1419
- // artifact's OWN scope's lock (capabilityManifests annotates it there), and
1420
- // that is the only row that can lock this artifact: the merged chain would
1421
- // let an outer scope's lock — or a lock-only ancestor with no config at all
1422
- // — silence an unlocked inner copy that WINS discovery precedence and
1423
- // activates. The legacy arm stays chain-merged: v1 parity is unchanged.
1424
- if (m._capabilityLock || Object.hasOwn(locks, id)) continue;
1425
- console.log(` WARNING: ${id} at ${shortPath(m._dir)} is in installed/ but has no lock entry — reacquire it or move it to owned/`);
1426
- }
1427
- 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`);
1428
-
1429
- // Workspace model v2: nothing is installed. Doctor reports the lock (v3) and
1430
- // the deployment declaration OFFLINE — membership, discovery and package
1431
- // resolution go to the remotes and belong to `oats sync` / `oats workspace status`.
563
+ const composition = await doctorComposition(ctx, soulName, ws, (code, msg) => die(`${msg} [${code}]`));
1432
564
  printDoctorWorkspace(ws);
1433
-
565
+ for (const p of legacyLayoutProblems(join(dirname(ws.local.path), "agents"))) console.log(`\n! ${p.code}: ${p.message}`);
1434
566
  if (soulName) {
1435
567
  const information = operationalKnowledgeNote(composition, soulName);
1436
568
  if (information) console.log(`\nINFO: ${information}`);
@@ -1448,7 +580,7 @@ function replaceLaunchConfigsBlock(text, serialized) {
1448
580
  const keyLine = /^(["']?)launch-configs\1:(\s*(?:#.*)?|\s+\S.*)?$/;
1449
581
  const lines = text.split("\n");
1450
582
  const starts = lines.map((l, i) => keyLine.test(l) ? i : -1).filter((i) => i >= 0);
1451
- if (starts.length > 1) throw Object.assign(new Error(`oats-config.yaml declares launch-configs ${starts.length} times (lines ${starts.map((i) => i + 1).join(", ")}); keep one`), { code: "E_CONFIG_BROKEN" });
583
+ if (starts.length > 1) throw Object.assign(new Error(`oats-local.yaml declares launch-configs ${starts.length} times (lines ${starts.map((i) => i + 1).join(", ")}); keep one`), { code: "E_CONFIG_BROKEN" });
1452
584
  const block = serialized ? serialized.replace(/\n$/, "").split("\n") : [];
1453
585
  if (!starts.length) {
1454
586
  if (!serialized) return text;
@@ -1513,11 +645,9 @@ function normalizeLaunchConfig(e) {
1513
645
  ...(e.yolo !== undefined ? { yolo: e.yolo } : {}),
1514
646
  };
1515
647
  }
1516
- function readLaunchConfigsModel(file) {
1517
- if (!existsSync(file)) return {};
1518
- const cfg = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8")));
1519
- const map = cfg["launch-configs"] || {};
1520
- for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
648
+ function readLaunchConfigsModel(local) {
649
+ const map = local["launch-configs"] || {};
650
+ for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, "oats-local.yaml");
1521
651
  const out = Object.create(null); // a name may be "constructor": membership is own only
1522
652
  for (const [n, e] of Object.entries(map)) out[n] = normalizeLaunchConfig(e);
1523
653
  return out;
@@ -1551,10 +681,8 @@ function launchConfigContext(bail) {
1551
681
  if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
1552
682
  const agentsRootFlag = flag("agents-root");
1553
683
  if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
1554
- let r;
1555
- try { r = resolveOatsConfig(ctx); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1556
684
  let soul;
1557
- try { soul = selectSoul(scopeSouls(ctx, r).souls, String(soulFlag), agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
685
+ try { soul = selectSoul(scopeSouls(ctx).souls, String(soulFlag), agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
1558
686
  return { context: memberContextOf(soul, ctx, flag("dir") !== undefined, bail), selected: { soul: soul.name, agentsRoot: soul.agentsRoot } };
1559
687
  }
1560
688
  return { context: ctx, selected: null };
@@ -1577,25 +705,25 @@ function launchPreview(bail) {
1577
705
  instance = meta.instance || basename(home);
1578
706
  if (!(meta.launch && typeof meta.launch === "object") && !selectionGiven) {
1579
707
  // A home that predates recipes, asked nothing: its frozen command is
1580
- // described as is. Under a selection it goes through the planner,
1581
- // whose narrow conversion is the one session restart uses.
708
+ // described as is. Under a selection the planner refuses it
709
+ // (E_LAUNCH_LEGACY: re-spawn it from the deployment).
1582
710
  let d;
1583
711
  try { d = describeLaunchCommand(meta.command); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
1584
- jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, runtime: null, model: null, yolo: null }, runtime: meta.runtime, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(meta.command), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; conversion on restart" }], ok: true });
712
+ jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, runtime: null, model: null, yolo: null }, runtime: meta.runtime, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(meta.command), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; a selection is refused (E_LAUNCH_LEGACY): re-spawn it" }], ok: true });
1585
713
  return;
1586
714
  }
1587
715
  const agentsRoot = agentsRootOfHome(home);
1588
716
  const agent = (() => { try { return findAgent(agentsRoot, meta.agent); } catch { return undefined; } })();
1589
717
  agentLike = agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo };
1590
718
  } else {
1591
- let r0;
1592
- try { r0 = resolveOatsConfig(context); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1593
- const soul = scopeSouls(context, r0).souls.find((x) => x.name === selected.soul && x.agentsRoot === selected.agentsRoot);
1594
- agentLike = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, "launch-config": soul.launchConfig };
719
+ const soul = scopeSouls(context).souls.find((x) => x.name === selected.soul && x.agentsRoot === selected.agentsRoot);
720
+ agentLike = { runtime: soul.runtime, model: soul.model };
1595
721
  instance = `${soul.name}-<purpose>`; home = join(selected.agentsRoot, soul.name, "instances", instance);
1596
722
  }
723
+ // A home's recorded capabilities; a new instance's are its spawn's resolution,
724
+ // which a preview of a soul does not prepare (spawn --preview does).
1597
725
  let r;
1598
- try { r = resolveOatsConfig(context, selected.soul || meta?.agent); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
726
+ try { r = meta ? resolvedFromHome(home, meta) : { capabilities: [], launchConfigs: launchConfigsAt(context) }; } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message, e.details); }
1599
727
  // The same planner a start uses, in preview mode: failed checks are listed, nothing is touched.
1600
728
  let plan;
1601
729
  try { plan = planLaunch({ home, instance, meta, contextDir: context, agentLike, selection: sel, resolvedCfg: r, preview: true }); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
@@ -1606,40 +734,46 @@ function launchPreview(bail) {
1606
734
  jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, runtime: sel.runtime ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, runtime: plan.runtime, model: recipe.model, modelSource: plan.modelSource, yolo: recipe.yolo ?? null, launchConfig: recipe.launchConfig, launchConfigSource: recipe.launchConfigSource, executable: { path: plan.executable.path, declared: plan.executable.declared ?? null, resolvedFrom: plan.executable.resolvedFrom }, argv: d.argv, environment, command, prompt: recipe.prompt, hooks: redactLaunchRecipe(recipe).hooks, preflight: plan.preflight, ok: plan.ok });
1607
735
  }
1608
736
  async function launchConfigCmd() {
1609
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
737
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1610
738
  dropAmbientRoot();
1611
739
  const sub = args[1];
1612
740
  const usage = "usage: oats launch-config list [--dir <scope> | --home <abs> | --soul <name> [--dir <scope>] [--agents-root <abs>]] [--json] | set <name> --file <json> [--keep-env] [--dir <scope>] [--json] | remove <name> [--dir <scope>] [--json] | preview (--home <abs> | --soul <name> [--dir <scope>]) [--launch-config <name>|none] [--runtime r] [--model m] [--yolo|--no-yolo] --json";
1613
741
  if (sub === "preview") { launchPreview(bail); return; }
1614
742
  if (!["list", "set", "remove"].includes(sub)) bail("E_USAGE", usage);
1615
743
  const { context: dir, selected } = sub === "list" ? launchConfigContext(bail) : { context: dirFlag(), selected: null };
1616
- if (sub !== "list" && (flag("home") !== undefined || flag("soul") !== undefined)) bail("E_BAD_ARGS", `launch-config ${sub} writes one scope's oats-config.yaml: address it with --dir, not --home or --soul`);
1617
- const level = levelOf(dir);
1618
- const file = join(dir, "oats-config.yaml");
1619
- const effective = () => {
1620
- const r = resolveOatsConfig(dir);
1621
- return Object.values(r.launchConfigs || {}).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, ...publicLaunchConfig(e, { source: e.source, shadows: e.shadows }) }));
1622
- };
744
+ if (sub !== "list" && (flag("home") !== undefined || flag("soul") !== undefined)) bail("E_BAD_ARGS", `launch-config ${sub} writes the deployment's oats-local.yaml: address it with --dir, not --home or --soul`);
745
+ // Lead decision 2: launch configurations are a HOST choice, declared in the
746
+ // deployment's oats-local.yaml (found walking up; a home's own deployment).
747
+ const at = selected?.home ?? dir;
748
+ // A 0.25 oats-config.yaml in reach is refused by loadLocal (E_CONFIG_BROKEN, naming
749
+ // the move), never read as "no configurations".
750
+ let found = null;
751
+ // No deployment in reach answers the (empty) effective set — unless what is in
752
+ // reach is a 0.25 oats-config.yaml, whose launch-configs nothing reads any more.
753
+ try { found = loadLocal(at); } catch (e) { if (e?.code !== "E_LOCAL_MISSING" || e.details?.legacy) bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
754
+ const file = found?.path ?? null, level = file ? dirname(file) : null;
755
+ const effective = () => Object.values(launchConfigsAt(at)).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, ...publicLaunchConfig(e, { source: e.source, shadows: e.shadows }) }));
1623
756
  if (sub === "list") {
1624
757
  let configurations;
1625
- try { configurations = effective(); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1626
- if (JSON_MODE) { jsonOk({ context: dir, level, file: existsSync(file) ? file : null, selected, configurations }); return; }
1627
- if (!configurations.length) { console.log(`No launch configurations are effective at ${dir}`); return; }
758
+ try { configurations = effective(); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
759
+ if (JSON_MODE) { jsonOk({ context: dir, level, file, selected, configurations }); return; }
760
+ if (!configurations.length) { console.log(`No launch configurations are declared${file ? ` in ${shortPath(file)}` : ` (no oats-local.yaml in reach of ${dir})`}`); return; }
1628
761
  for (const c of configurations) {
1629
762
  const env = Object.entries(c.env).map(([n, v]) => v.fromEnv ? `${n}=$${v.fromEnv}` : `${n}=<redacted>`).join(" ");
1630
- console.log(`${c.name}: ${c.runtime}${c.executable ? ` ${c.executable}` : ""}${c.args.length ? ` ${c.args.map((a) => JSON.stringify(a)).join(" ")}` : ""}${env ? ` [${env}]` : ""}${c.model ? ` model ${c.model}` : ""}${c.yolo !== null ? ` yolo ${c.yolo}` : ""} (${c.source}${c.shadows.length ? `; shadows ${c.shadows.join(", ")}` : ""})`);
763
+ console.log(`${c.name}: ${c.runtime}${c.executable ? ` ${c.executable}` : ""}${c.args.length ? ` ${c.args.map((a) => JSON.stringify(a)).join(" ")}` : ""}${env ? ` [${env}]` : ""}${c.model ? ` model ${c.model}` : ""}${c.yolo !== null ? ` yolo ${c.yolo}` : ""}`);
1631
764
  }
1632
765
  return;
1633
766
  }
767
+ if (!file) bail("E_LOCAL_MISSING", `launch-config ${sub} writes the deployment's oats-local.yaml, and none is in reach of ${dir} — run it from the deployment (\`oats onboard\` creates one)`);
1634
768
  const name = args[2];
1635
769
  if (!name || name.startsWith("--")) bail("E_BAD_ARGS", `launch-config ${sub} needs a configuration name`);
1636
- const text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
770
+ const text = readFileSync(file, "utf8");
1637
771
  let model;
1638
- try { model = readLaunchConfigsModel(file); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
772
+ try { model = readLaunchConfigsModel(found.local); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
1639
773
  const declaredHere = Object.hasOwn(model, name);
1640
774
  const before = declaredHere ? publicLaunchConfig(model[name]) : null;
1641
775
  if (sub === "remove") {
1642
- if (!declaredHere) bail("E_LAUNCH_CONFIG_UNKNOWN", `${name} is not declared at ${level} level (${shortPath(file)}); an inherited configuration is removed at the scope that declares it`);
776
+ if (!declaredHere) bail("E_LAUNCH_CONFIG_UNKNOWN", `${name} is not declared in ${shortPath(file)}`);
1643
777
  delete model[name];
1644
778
  } else {
1645
779
  const f = flag("file");
@@ -1659,13 +793,11 @@ async function launchConfigCmd() {
1659
793
  try { entry = JSON.parse(raw); } catch { bail("E_BAD_ARGS", `--file ${f} is not valid JSON (one object with runtime and optional executable, args, env, model, yolo)`); }
1660
794
  if (args.includes("--keep-env")) {
1661
795
  // An editor that saw only redacted values keeps the environment of the
1662
- // definition EFFECTIVE at this scope for that name (this scope's own, or
1663
- // the inherited one it is overriding): a one-time copy into the complete
1664
- // replacement entry, not inheritance; whole-entry shadowing stays.
1665
- if (entry && typeof entry === "object" && entry.env !== undefined) bail("E_BAD_ARGS", "--keep-env keeps the environment of the effective definition; omit env from --file");
1666
- let current;
1667
- try { const all = resolveOatsConfig(dir).launchConfigs || {}; current = Object.hasOwn(all, name) ? all[name] : undefined; } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
1668
- if (!current) bail("E_LAUNCH_CONFIG_UNKNOWN", `--keep-env: no launch configuration ${name} is effective at ${dir}, so there is no environment to keep; declare it with env`);
796
+ // declared definition of that name: a one-time copy into the complete
797
+ // replacement entry.
798
+ if (entry && typeof entry === "object" && entry.env !== undefined) bail("E_BAD_ARGS", "--keep-env keeps the environment of the declared definition; omit env from --file");
799
+ const current = declaredHere ? model[name] : undefined;
800
+ if (!current) bail("E_LAUNCH_CONFIG_UNKNOWN", `--keep-env: no launch configuration ${name} is declared in ${shortPath(file)}, so there is no environment to keep; declare it with env`);
1669
801
  if (entry && typeof entry === "object" && Object.keys(current.env || {}).length) entry.env = { ...current.env };
1670
802
  }
1671
803
  try { validateLaunchConfig(name, entry, `--file ${f}`); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
@@ -1676,7 +808,10 @@ async function launchConfigCmd() {
1676
808
  // What is written must read back as exactly what was asked, by the kernel's
1677
809
  // own reader, before a byte of the file changes.
1678
810
  let readBack;
1679
- try { readBack = parseYamlNested(next)["launch-configs"] || {}; } catch (e) { bail("E_LAUNCH_CONFIG_INVALID", `the rewritten block does not parse: ${e.message}; nothing was written`); }
811
+ let nextLocal;
812
+ try { nextLocal = parseConfigData(next, { origin: { kind: "local", path: file } }).value; readBack = nextLocal["launch-configs"] || {}; } catch (e) { bail("E_LAUNCH_CONFIG_INVALID", `the rewritten block does not parse: ${e.message}; nothing was written`); }
813
+ const schemaProblems = validateLocal(nextLocal);
814
+ if (schemaProblems.length) bail("E_LAUNCH_CONFIG_INVALID", `the rewritten oats-local.yaml would be invalid (${schemaProblems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`);
1680
815
  const canonical = (m) => JSON.stringify(Object.keys(m).sort().map((n) => [n, normalizeLaunchConfig(m[n])]));
1681
816
  const same = canonical(readBack) === canonical(model);
1682
817
  if (!same) bail("E_LAUNCH_CONFIG_INVALID", `${name} would not read back as written; nothing was written`);
@@ -1685,7 +820,7 @@ async function launchConfigCmd() {
1685
820
  try { eff = effective().find((c) => c.name === name) || null; } catch (e) { eff = { error: e.message }; }
1686
821
  const receipt = { name, action: sub, level, file, before, after: Object.hasOwn(model, name) ? publicLaunchConfig(model[name]) : null, effective: eff };
1687
822
  if (JSON_MODE) { jsonOk(receipt); return; }
1688
- console.log(sub === "set" ? `Declared launch configuration ${name} at ${level} level (${shortPath(file)})` : `Removed launch configuration ${name} from ${level} level (${shortPath(file)})${eff ? `; ${eff.source} now provides it` : ""}`);
823
+ console.log(sub === "set" ? `Declared launch configuration ${name} in ${shortPath(file)}` : `Removed launch configuration ${name} from ${shortPath(file)}`);
1689
824
  }
1690
825
 
1691
826
 
@@ -1752,8 +887,7 @@ function instanceCmd() {
1752
887
  if (home === undefined) {
1753
888
  let root;
1754
889
  try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1755
- let r; try { r = resolveOatsConfig(dirFlag()); } catch (e) { return bail(e.code || "E_CONFIG_BROKEN", e.message); }
1756
- const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])].map((p) => realOrResolved(p)))];
890
+ const roots = [realOrResolved(root)];
1757
891
  const candidates = [];
1758
892
  for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
1759
893
  if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
@@ -1783,48 +917,36 @@ function instanceCmd() {
1783
917
  bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
1784
918
  }
1785
919
  }
1786
- /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
1787
- function readinessCmd() {
920
+ /** `oats readiness (--soul <name> [--agents-root <abs>] [--dir <d>] | --home <abs>) [--policy] --json` — readinessApi 2. */
921
+ async function readinessCmd() {
1788
922
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1789
923
  dropAmbientRoot();
1790
- // A captured incarnation's readiness comes from its retained resolution, not
1791
- // from the current configuration this command reads; refuse before inspecting.
1792
924
  const homeArg = flag("home");
1793
- if (homeArg && homeArg !== true) {
1794
- let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
1795
- if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
1796
- }
1797
- const inspect = computeInspect({ onFail: bail });
1798
- if (!inspect) return;
1799
- const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
1800
- const verify = args.includes("--verify-signatures");
1801
- let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
1802
- const deploymentDir = inspect.scope?.context ?? null;
1803
- // Echo the exact selector this read was made with, so a consumer can bind the
1804
- // result to its own admitted target without inventing a revision.
1805
- // Every field is the argument AS GIVEN (no realpath): a consumer compares it
1806
- // byte-exact with what it sent. The canonical scope is subject.context.
1807
- const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
1808
- const agentsRootArg = given("agents-root"), dirArg = given("dir");
1809
- const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
1810
- : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
1811
- : { kind: "scope", dir: dirArg };
1812
- const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
1813
- if (args.includes("--policy")) {
1814
- const homeOpt = flag("home");
1815
- let meta = null;
1816
- if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
1817
- readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
1818
- readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
1819
- }
1820
- if (JSON_MODE) { jsonOk(readiness); return; }
1821
- console.log(`readiness — ${readiness.subject.kind === "soul" ? `soul ${readiness.subject.name}` : shortPath(readiness.subject.context)}: ${readiness.summary.ready ? "READY" : `${readiness.summary.fail} failing, ${readiness.summary.unknown} unknown of ${readiness.summary.required} required`}`);
1822
- for (const [name, check] of Object.entries(readiness.checks)) {
1823
- console.log(` ${name}: ${check.status}`);
1824
- for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.signature ? ` · signature ${i.signature.status}${i.signature.signer?.label ? ` by ${i.signature.signer.label}` : ""}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
925
+ // Workspace model (readinessApi 2): an instance or soul subject; checks
926
+ // installed | configured | member | providers. No trusted check.
927
+ if (args.includes("--verify-signatures")) return bail("E_BAD_ARGS", "--verify-signatures was removed with the trusted check (readinessApi 2): declaring a package in packages: is the trust decision, and the lock pins commit + integrity");
928
+ const target = await workspaceTarget(bail, { command: "readiness" });
929
+ {
930
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
931
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul: given("soul"), agentsRoot: given("agents-root") }
932
+ : { kind: "soul", soul: String(flag("soul")), agentsRoot: given("agents-root"), dir: given("dir") };
933
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
934
+ const doc = await readinessDocument(target, { selector, remoteOptions: remoteOptionsFromEnv(), catalog });
935
+ if (args.includes("--policy")) {
936
+ doc.policy = policyOf({ instanceMeta: target.meta, soul: target.meta ? null : policySoul(target) }).policy;
937
+ doc.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
938
+ }
939
+ if (JSON_MODE) { jsonOk(doc); return; }
940
+ const s = doc.subject;
941
+ console.log(`readiness — ${s.kind === "instance" ? `instance ${s.instance} (soul ${s.soul})` : `soul ${s.soul}`}: ${doc.summary.ready ? "READY" : `${doc.summary.fail} failing, ${doc.summary.unknown} unknown of ${doc.summary.required} required`}`);
942
+ for (const [name, check] of Object.entries(doc.checks)) {
943
+ console.log(` ${name}: ${check.status}`);
944
+ for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
945
+ }
946
+ if (doc.policy) console.log(` policy: child spawns ${doc.policy.childSpawns.allowed ? "allowed" : "disabled"} (${doc.policy.childSpawns.origin.kind}${doc.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${doc.policy.worktrees.allowed === null ? "unknown" : doc.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
947
+ for (const n of doc.notes) console.log(` note: ${n}`);
948
+ return;
1825
949
  }
1826
- if (readiness.policy) console.log(` policy: child spawns ${readiness.policy.childSpawns.allowed ? "allowed" : "disabled"} (${readiness.policy.childSpawns.origin.kind}${readiness.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${readiness.policy.worktrees.allowed === null ? "unknown" : readiness.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
1827
- for (const n of readiness.notes) console.log(` note: ${n}`);
1828
950
  }
1829
951
  // ---------- workspace model v2: sync / package / workspace status / capabilities / souls ----------
1830
952
  // Contract: docs/design/2026-09-23-workspace-module-contracts.md §6. Nothing is
@@ -1862,12 +984,13 @@ function catalogForSync(bail) {
1862
984
  * recomposes the tag from the catalog's own convention.
1863
985
  * → { packages: { <id>: <version> }, problems: [ { code: "E_PACKAGE_MISSING", … } ] } */
1864
986
  function standalonePackages(catalog) {
1865
- let id = "oats.framework", file = process.env.OATS_PACKAGE_CATALOG || null;
1866
- try { const d = describeOfficialCatalog(); file = d.catalog.file; id = d.capabilityAliases.find((a) => a.capability === "oats.core")?.package ?? id; } catch { /* the catalog is diagnosed below */ }
987
+ let id = "oats.framework";
988
+ const file = officialCatalogFile();
989
+ try { const alias = officialCapabilityAliases()["oats.core"]; id = (typeof alias === "string" ? alias : alias?.package) ?? id; } catch { /* the catalog is diagnosed below */ }
1867
990
  const version = standaloneCatalogVersion(catalog?.[id]?.ref);
1868
991
  if (version) return { packages: { [id]: version }, problems: [] };
1869
992
  const why = catalog?.[id] ? `its ref ${JSON.stringify(catalog[id].ref)} carries no version` : `it has no entry ${JSON.stringify(id)}`;
1870
- return { packages: {}, problems: [{ code: "E_PACKAGE_MISSING", id, reason: "no-catalog", catalog: file, path: `/packages/${id}`, message: `the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}): ${why}; standalone spawns will be refused until it does` }] };
993
+ return { packages: {}, problems: [{ code: "E_PACKAGE_MISSING", id, reason: "no-catalog", catalog: file, path: `/packages/${id}`, message: `the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file}): ${why}; standalone spawns will be refused until it does` }] };
1871
994
  }
1872
995
  /** The bare version of a catalog ref: its LAST path segment when that is a version
1873
996
  * (`v1.1.3` → `v1.1.3`; `oats-framework/v1.1.3` → `v1.1.3`; `main` → null). */
@@ -1889,31 +1012,29 @@ async function discoverForCli(ctx, bail) {
1889
1012
  }
1890
1013
 
1891
1014
  const short = (oid) => (typeof oid === "string" ? oid.slice(0, 8) : "?");
1892
- /** Where a reader takes capability manifests from: the instance home's own
1893
- * materialized modules when the home has them (workspace model), else the
1894
- * context directory (classic chain). */
1895
- const manifestSource = (meta, home, ctx) => (meta && meta.modules && typeof meta.modules === "object" && home ? realOrResolved(home) : ctx);
1896
1015
  /** Display name of a discovery: the workspace's name, or the standalone label (decision 10). */
1897
1016
  const workspaceName = (discovery) => discovery.workspace?.name ?? `standalone:${memberLabel(discovery.key)}`;
1898
1017
  const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || String(key);
1899
1018
  const teamLabel = (team) => team ?? "unassigned";
1900
1019
  const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1901
1020
 
1902
- /** Rows of every non-private soul/capability of confirmed members + locked package capabilities. */
1903
- function workspaceItems(discovery, lock, { includePrivate = false } = {}) {
1021
+ /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1022
+ * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1023
+ * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE). */
1024
+ function workspaceItems(discovery, lock) {
1904
1025
  const souls = [];
1905
1026
  const capabilities = [];
1906
1027
  for (const m of discovery.members) {
1907
1028
  if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
1908
- for (const s of m.souls) if (includePrivate || !s.private) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1909
- for (const c of m.capabilities) if (includePrivate || !c.private) capabilities.push({ name: c.name, origin: originOf(c), kind: "member", repoKey: c.repoKey, commit: c.commit, team: teamLabel(c.team), private: c.private, path: c.path, layer: c.manifest.layer ?? null, version: c.manifest.version ?? null });
1029
+ for (const s of m.souls) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1030
+ for (const c of m.capabilities) capabilities.push({ name: c.name, origin: originOf(c), kind: "member", repoKey: c.repoKey, commit: c.commit, team: teamLabel(c.team), private: c.private, path: c.path, layer: c.manifest.layer ?? null, version: c.manifest.version ?? null });
1910
1031
  }
1911
1032
  for (const ext of discovery.external || []) {
1912
1033
  const s = ext.soul;
1913
- if (includePrivate || !s.private) souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1034
+ souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1914
1035
  }
1915
1036
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1916
- for (const name of entry.capabilities) capabilities.push({ name, origin: originOf({ package: id, version: entry.version }), kind: "package", package: id, version: entry.version, commit: entry.commit, team: teamLabel(null), private: false, approved: !!entry.approved });
1037
+ for (const name of entry.capabilities) capabilities.push({ name, origin: originOf({ package: id, version: entry.version }), kind: "package", package: id, version: entry.version, commit: entry.commit, team: teamLabel(null), private: false });
1917
1038
  }
1918
1039
  const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
1919
1040
  souls.sort(byName);
@@ -1931,7 +1052,7 @@ function memberRows(discovery) {
1931
1052
 
1932
1053
  /** Package rows for `sync` / `workspace status` from the lock. */
1933
1054
  function packageRows(lock) {
1934
- return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
1055
+ return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities }));
1935
1056
  }
1936
1057
 
1937
1058
  /** Print a padded table: rows are arrays of strings. */
@@ -1941,55 +1062,17 @@ function printTable(header, rows) {
1941
1062
  for (const r of all) console.log(" " + r.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1942
1063
  }
1943
1064
 
1944
- /** Executables digest of a locked package, read over the remote at its locked commit. */
1945
- async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOptions) {
1946
- // ONE definition of the approval digest (lib/packages.mjs executablesDigestAt) — the
1947
- // same function resolveSoul re-runs at spawn (M3), so sync and spawn can never disagree.
1948
- const req = parsePackageRequest(id, workspace.packages[id], catalog);
1949
- const { digest, executables } = await executablesDigestAt(remoteModule, req.remoteRef, entry.commit, entry.path, entry.capabilities ?? null, { remoteOptions });
1950
- const targets = executables.map((x) => `${x.capability}: ${x.kind} ${x.name} → ${x.target}`);
1951
- return { digest, targets };
1952
- }
1953
-
1954
- /** One yes/no question on the terminal (TTY only; the caller checks). Ctrl+D / a closed
1955
- * stdin at the prompt is "no" (readline rejects the question with AbortError, or simply
1956
- * closes without an answer — neither is a crash; both are the operator declining). */
1957
- async function askYesNo(question) {
1958
- const rl = createInterface({ input: process.stdin, output: process.stderr });
1959
- try {
1960
- const closed = new Promise((resolveClosed) => rl.once("close", () => resolveClosed(null)));
1961
- const answer = await Promise.race([rl.question(question).catch((e) => { if (e?.code === "ABORT_ERR" || e?.name === "AbortError") return null; throw e; }), closed]);
1962
- if (answer === null) { process.stderr.write("\n"); return false; }
1963
- const a = String(answer).trim().toLowerCase();
1964
- return a === "y" || a === "yes";
1965
- } finally { rl.close(); }
1966
- }
1967
-
1968
- /** `--approve <id>@<version>` (repeatable) → Map<id, version>. Exactly one "@" splits the two;
1969
- * the digest is never taken from the operator — it is computed over the locked tree. */
1970
- function approveFlags(bail) {
1971
- const out = new Map();
1972
- for (let i = 0; i < args.length; i++) {
1973
- if (args[i] !== "--approve") continue;
1974
- const v = args[i + 1];
1975
- if (v === undefined || v.startsWith("--")) return bail("E_BAD_ARGS", "--approve needs <id>@<version> (the package id and the version exactly as `packages:` / the lock spell them)");
1976
- const at = v.indexOf("@");
1977
- const id = at > 0 ? v.slice(0, at) : "";
1978
- const version = at > 0 ? v.slice(at + 1) : "";
1979
- if (!id || !version || version.includes("@")) return bail("E_BAD_ARGS", `--approve ${JSON.stringify(v)}: expected <id>@<version>`, { value: v });
1980
- if (out.has(id) && out.get(id) !== version) return bail("E_BAD_ARGS", `--approve names ${id} twice with different versions (${out.get(id)}, ${version})`, { id, versions: [out.get(id), version] });
1981
- out.set(id, version);
1982
- i++;
1983
- }
1984
- return out;
1985
- }
1065
+ /** `--approve` left with package approval (human decision 2026-09-24). */
1066
+ const APPROVAL_REMOVED = "package approval was removed; declaring a package in packages: is the trust decision";
1986
1067
 
1987
1068
  /** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
1988
1069
  * way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
1989
- * `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
1070
+ * `packages:` against the lock (commit + integrity), write the lock. There is no approval step:
1071
+ * declaring a package in `packages:` is the trust decision (human decision 2026-09-24).
1990
1072
  * `bail` never returns (it exits the process with the caller's error shape).
1991
- * → { report, lock, discovery, approvalNeeded, interactive, items, lockFile } */
1073
+ * → { report, lock, discovery, items, lockFile, problems } */
1992
1074
  async function performSync(ctx, bail, { onDiscovered } = {}) {
1075
+ if (args.includes("--approve")) return bail("E_BAD_ARGS", `--approve: ${APPROVAL_REMOVED}`, { flag: "--approve" });
1993
1076
  const catalog = catalogForSync(bail);
1994
1077
  const discovery = await discoverForCli(ctx, bail);
1995
1078
  onDiscovered?.(discovery);
@@ -2007,37 +1090,7 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
2007
1090
  if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
2008
1091
  throw e;
2009
1092
  }
2010
- let lock = resolved.lock;
2011
- const approvalNeeded = [];
2012
- const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
2013
- // `--approve <id>@<version>`: approve what is THERE — every named pair must be a locked entry
2014
- // at exactly that version (E_BAD_ARGS otherwise); the digest recorded is the one computed over
2015
- // the locked tree, never anything the operator typed. Works without a terminal.
2016
- const approveWanted = approveFlags(bail);
2017
- for (const [id, version] of approveWanted) {
2018
- const entry = lock.packages[id];
2019
- if (!entry) return bail("E_BAD_ARGS", `--approve ${id}@${version}: ${id} is not a package of this ${discovery.standalone === true ? "standalone view" : "workspace"} (locked: ${Object.keys(lock.packages).sort().join(", ") || "none"})`, { id, version, locked: Object.keys(lock.packages).sort() });
2020
- if (entry.version !== version) return bail("E_BAD_ARGS", `--approve ${id}@${version}: the lock resolves ${id} to version ${entry.version} (@ ${short(entry.commit)}) — approve what is there: --approve ${id}@${entry.version}`, { id, version, locked: entry.version, commit: entry.commit });
2021
- }
2022
- const approvedNow = [];
2023
- for (const id of Object.keys(lock.packages)) {
2024
- const entry = lock.packages[id];
2025
- if (entry.approved) continue;
2026
- let digest, targets;
2027
- try { ({ digest, targets } = await lockedExecutablesDigest(id, entry, packageSource, catalog, ctx.remoteOptions)); }
2028
- catch (e) {
2029
- if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
2030
- throw e;
2031
- }
2032
- if (approveWanted.has(id)) { lock = approvePackage(lock, id, digest); approvedNow.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets }); continue; }
2033
- if (interactive) {
2034
- console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
2035
- for (const t of targets) console.error(` ${t}`);
2036
- if (targets.length === 0) console.error(" (no commands or hooks — nothing runs unattended)");
2037
- if (await askYesNo(`approve ${id} ${entry.version}? [y/N] `)) { lock = approvePackage(lock, id, digest); continue; }
2038
- }
2039
- approvalNeeded.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets });
2040
- }
1093
+ const lock = resolved.lock;
2041
1094
  let lockFile;
2042
1095
  try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2043
1096
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
@@ -2046,9 +1099,9 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
2046
1099
  const members = memberRows(discovery);
2047
1100
  const packages = packageRows(lock);
2048
1101
  const changes = resolved.changes;
2049
- const items = workspaceItems(discovery, lock, { includePrivate: true });
2050
- const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, approvalNeeded, ...(approvedNow.length ? { approved: approvedNow } : {}), problems };
2051
- return { report, lock, discovery, approvalNeeded, approvedNow, interactive, items, lockFile, problems };
1102
+ const items = workspaceItems(discovery, lock);
1103
+ const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, problems, warnings: discovery.warnings ?? [] };
1104
+ return { report, lock, discovery, items, lockFile, problems };
2052
1105
  }
2053
1106
 
2054
1107
  /** The one-line standalone explanation (decision 10). `standaloneReason` says WHY the view is
@@ -2065,32 +1118,28 @@ function standaloneNote(discovery, { from = "" } = {}) {
2065
1118
 
2066
1119
  /** The human §8 report of a sync (text mode). */
2067
1120
  function printSyncReport(ctx, synced) {
2068
- const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
1121
+ const { report, discovery, items, lockFile } = synced;
2069
1122
  const { members, packages, changes } = report;
2070
1123
  const disabled = new Set(ctx.local.souls?.disabled || []);
2071
1124
  console.log(`workspace ${discovery.workspace?.name ?? `(${standaloneNote(discovery)})`} (${discovery.key} @ ${short(discovery.commit)})`);
2072
1125
  console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
2073
- console.log(`packages ${packages.map((p) => {
2074
- const need = approvalNeeded.find((a) => a.id === p.id);
2075
- return `${p.id} ${p.version} ✓ (${p.approved ? "approved" : need ? "approval needed" : "unapproved"})`;
2076
- }).join(" ") || "(none)"}`);
1126
+ console.log(`packages ${packages.map((p) => `${p.id} ${p.version} ✓ (@ ${short(p.commit)})`).join(" ") || "(none)"}`);
2077
1127
  const changed = changes.filter((c) => c.to !== null && c.from !== c.to).map((c) => `${c.id} ${c.from ?? "—"} → ${c.to} (@ ${short(c.commit)})`);
2078
1128
  const removed = changes.filter((c) => c.to === null).map((c) => `${c.id} ${c.from} → removed`);
2079
1129
  console.log(`changed ${[...changed, ...removed].join(" ") || "(nothing — the lock already described this workspace)"}`);
2080
1130
  const memberSouls = items.souls.filter((s) => s.kind === "member");
2081
1131
  const externalSouls = items.souls.filter((s) => s.kind === "external");
2082
- const privateSouls = memberSouls.filter((s) => s.private);
1132
+ // Souls have no private mode (0.26.0); the private count is of repo-owned member capabilities.
1133
+ const repoOwned = items.capabilities.filter((c) => c.kind === "member" && c.private);
2083
1134
  const disabledHere = items.souls.filter((s) => disabled.has(s.name));
2084
- console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${disabledHere.length} disabled here) · ${privateSouls.length} private${privateSouls.length ? ` (${privateSouls.map((s) => `${s.name}, ${memberLabel(s.repoKey)} only`).join("; ")})` : ""}`);
1135
+ console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${disabledHere.length} disabled here) · ${repoOwned.length} private capabilit${repoOwned.length === 1 ? "y" : "ies"}${repoOwned.length ? ` (${repoOwned.map((c) => `${c.name}, ${memberLabel(c.repoKey)} only`).join("; ")})` : ""}`);
2085
1136
  const teams = new Map();
2086
1137
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
2087
1138
  for (const c of items.capabilities.filter((c) => c.kind === "member")) { const t = teams.get(c.team) || { souls: 0, capabilities: 0 }; t.capabilities++; teams.set(c.team, t); }
2088
1139
  console.log(`teams ${[...teams.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([team, n]) => `${team} ${n.souls} soul${n.souls === 1 ? "" : "s"}${n.capabilities ? `, ${n.capabilities} capabilit${n.capabilities === 1 ? "y" : "ies"}` : ""}`).join(" · ") || "(none)"}`);
2089
1140
  for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
2090
- for (const a of synced.approvedNow ?? []) console.log(`approved ${a.id} ${a.version} @ ${short(a.commit)} (--approve; ${a.targets.length} executable${a.targets.length === 1 ? "" : "s"}, digest ${a.executables})`);
2091
- if (approvalNeeded.length) {
2092
- console.log(`\nApproval needed for ${approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")} — ${interactive ? "declined; " : "not a terminal; "}the lock records them unapproved. Run \`oats sync\` in a terminal to approve their executables (spawns of souls using them are refused until then).`);
2093
- } else console.log(`\nlock ${shortPath(lockFile)}`);
1141
+ for (const w of discovery.warnings ?? []) console.log(`warning ${w.code} ${w.message}`);
1142
+ console.log(`\nlock ${shortPath(lockFile)}`);
2094
1143
  }
2095
1144
 
2096
1145
  /** `oats sync [--dir] [--json]` — contract §6. */
@@ -2098,10 +1147,8 @@ async function syncCmd() {
2098
1147
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2099
1148
  const ctx = workspaceContext(bail);
2100
1149
  const synced = await performSync(ctx, bail);
2101
- // One envelope (or the §8 report), then the exit status: 2 = the lock is written but approvals
2102
- // are pending. exitCode (not process.exit) lets stdout drain when it is a pipe.
1150
+ // One envelope (or the §8 report); success is exit 0 (a failure bails with its code).
2103
1151
  if (JSON_MODE) jsonOk(synced.report); else printSyncReport(ctx, synced);
2104
- process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2105
1152
  }
2106
1153
 
2107
1154
  /** Walk up from dir for oats-workspace.yaml INSIDE a Git checkout → { file, root } | null. */
@@ -2186,7 +1233,7 @@ async function packageCmd() {
2186
1233
  const receipt = { action: sub, id, value: value ?? null, previous: had ?? null, edited: true, file: checkout.file };
2187
1234
  if (JSON_MODE) { jsonOk(receipt); return; }
2188
1235
  console.log(sub === "add"
2189
- ? `${had === undefined ? "Added" : `Changed (${had} →)`} packages.${id}: ${value} in ${shortPath(checkout.file)}. Commit it, then \`oats sync\` (the lock resolves the version to a commit and asks approval once).`
1236
+ ? `${had === undefined ? "Added" : `Changed (${had} →)`} packages.${id}: ${value} in ${shortPath(checkout.file)}. Commit it, then \`oats sync\` (the lock resolves the version to a commit).`
2190
1237
  : `Removed packages.${id} (${had}) from ${shortPath(checkout.file)}. Commit it, then \`oats sync\`.`);
2191
1238
  }
2192
1239
 
@@ -2207,8 +1254,7 @@ async function workspaceCmd() {
2207
1254
  const locked = new Set(packages.map((p) => p.id));
2208
1255
  const unsynced = declared.filter((id) => !locked.has(id));
2209
1256
  const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
2210
- const approval = { approved: packages.filter((p) => p.approved).map((p) => p.id), needed: packages.filter((p) => !p.approved).map((p) => p.id) };
2211
- const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, approval, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems };
1257
+ const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems, warnings: discovery.warnings ?? [] };
2212
1258
  if (JSON_MODE) { jsonOk(result); return; }
2213
1259
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
2214
1260
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
@@ -2217,11 +1263,12 @@ async function workspaceCmd() {
2217
1263
  for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
2218
1264
  console.log("\nPackages:");
2219
1265
  if (!packages.length) console.log(unsynced.length ? ` (none locked yet — \`oats sync\` resolves ${unsynced.join(", ")})` : " (none)");
2220
- else printTable(["package", "version", "source", "commit", "approval", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.approved ? `approved ${p.approved.at.slice(0, 10)}` : "NEEDED", p.capabilities.join(",")]));
1266
+ else printTable(["package", "version", "source", "commit", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.capabilities.join(",")]));
2221
1267
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
2222
1268
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
2223
1269
  if (result.external.length) console.log(`\nExternal: ${result.external.map((e) => `${e.soul} (${e.source.replace(/@([0-9a-f]{40})$/, (_, o) => `@${short(o)}`)}, ${e.team})`).join(" ")}`);
2224
1270
  if (discovery.problems.length) { console.log("\nProblems:"); for (const p of discovery.problems) console.log(` ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`); }
1271
+ if (discovery.warnings?.length) { console.log("\nWarnings:"); for (const w of discovery.warnings) console.log(` ${w.code} ${w.message}`); }
2225
1272
  }
2226
1273
 
2227
1274
  /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
@@ -2236,8 +1283,8 @@ async function itemsCmd(kind) {
2236
1283
  if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
2237
1284
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
2238
1285
  if (!items.length) console.log(" (none)");
2239
- else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
2240
- else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
1286
+ else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.labels?.length > 1 ? s.labels.join(",") : s.team, s.work ?? "—"]));
1287
+ else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.private ? `${c.name} (repo-owned)` : c.name, c.origin, c.team, c.layer ?? "—"]));
2241
1288
  const unsynced = Object.keys(discovery.workspace?.packages || {}).filter((id) => !lock.packages[id]);
2242
1289
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
2243
1290
  }
@@ -2252,7 +1299,13 @@ async function itemsCmd(kind) {
2252
1299
  * present) to its stamp — the roster's `repo:` column for a workspace soul. */
2253
1300
  async function statusDrift(data) {
2254
1301
  let ctx;
2255
- try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
1302
+ try { ctx = loadLocal(dirFlag()); }
1303
+ catch (e) {
1304
+ if (e?.code === "E_LOCAL_MISSING") return null;
1305
+ // A 0.25 oats-config.yaml inside the deployment: the typed migration error, never a stack.
1306
+ if (e?.code === "E_CONFIG_BROKEN") { if (JSON_MODE) jsonFail(e.code, e.message, e.details); die(e.message); }
1307
+ throw e;
1308
+ }
2256
1309
  const hasModules = (i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length > 0;
2257
1310
  const hasSoul = (i) => i.workspace && typeof i.workspace === "object" && i.workspace.soul && typeof i.workspace.soul === "object" && typeof i.workspace.soul.repoKey === "string";
2258
1311
  // Workspace souls of this roster: the stamp ensureWorkspaceSoul leaves beside the soul pointer.
@@ -2316,13 +1369,14 @@ function soulRepoLabel(a, ws) {
2316
1369
  const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
2317
1370
 
2318
1371
  async function status() {
2319
- if (args.includes("--team")) return statusTeam();
1372
+ if (args.includes("--team")) { const msg = "status --team was removed with the classic team scope: `oats status` in the deployment lists every instance, and `oats workspace status` shows the members"; if (JSON_MODE) jsonFail("E_BAD_ARGS", msg); die(msg); }
2320
1373
  let root;
2321
1374
  try { root = ensureRoot(dirFlag()); }
2322
1375
  catch (e) { if (e?.code === "E_NO_DEPLOYMENT") { if (JSON_MODE) jsonFail("E_NO_DEPLOYMENT", e.message, e.details ?? e.provenance); die(e.message); } throw e; }
2323
1376
  const data = listInstances(root);
2324
1377
  const ws = await statusDrift(data);
2325
1378
  const verbose = args.includes("--verbose");
1379
+ const problems = legacyLayoutProblems(root);
2326
1380
  if (args.includes("--json")) {
2327
1381
  if (ws) for (const a of data) {
2328
1382
  const stamp = ws.souls.get(a.name);
@@ -2335,13 +1389,14 @@ async function status() {
2335
1389
  if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}) };
2336
1390
  }
2337
1391
  }
2338
- console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
1392
+ console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}) }, null, 2)); return;
2339
1393
  }
2340
1394
  console.log(`oats status — agents root ${shortPath(root)}\n`);
2341
1395
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
2342
- if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
1396
+ for (const p of problems) console.log(` ! ${p.code}: ${p.message}\n`);
1397
+ if (data.length === 0) { console.log(" (no agents — a soul is a member repository's souls/<name>; `oats souls` lists the workspace's)"); return; }
2343
1398
  for (const a of data) {
2344
- console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
1399
+ console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
2345
1400
  if (a.description) console.log(` ${a.description}`);
2346
1401
  for (const i of a.instances) {
2347
1402
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
@@ -2356,29 +1411,6 @@ async function status() {
2356
1411
  console.log(` ! deferred retirement of ${f.instance} FAILED${f.completedAt ? ` at ${f.completedAt}` : ""}: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
2357
1412
  }
2358
1413
  }
2359
- const defs = listAgentDefs(process.cwd());
2360
- if (defs.length) console.log(`\n importable defs: ${defs.map((d) => d.name).join(", ")}`);
2361
- }
2362
-
2363
- function statusTeam() {
2364
- const ctx = dirFlag();
2365
- const r = resolveOatsConfig(ctx);
2366
- 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`);
2367
- const roots = teamAgentRoots(r.team.scope);
2368
- const payload = { team: r.team, roots: [] };
2369
- for (const root of roots) payload.roots.push({ root, agents: listInstances(root) });
2370
- if (args.includes("--json")) { console.log(JSON.stringify(payload, null, 2)); return; }
2371
- console.log(`oats status — team ${r.team.name}${r.team.id ? ` (${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]\n`);
2372
- if (!roots.length) { console.log(" (no agents/ directories in the team scope)"); return; }
2373
- for (const { root, agents } of payload.roots) {
2374
- console.log(` ${shortPath(root)}`);
2375
- if (!agents.length) { console.log(" (no agents)"); continue; }
2376
- for (const a of agents) {
2377
- console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""}${a.description ? ` — ${a.description}` : ""}`);
2378
- for (const i of a.instances) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"}`);
2379
- for (const f of a.retireFailures || []) console.log(` ! deferred retirement of ${f.instance} FAILED: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
2380
- }
2381
- }
2382
1414
  }
2383
1415
 
2384
1416
  async function spawnCmd() {
@@ -2393,90 +1425,96 @@ async function spawnCmd() {
2393
1425
  const checkDirectoryOptions = (work) => {
2394
1426
  if (work === "directory" && (workDir !== undefined || branch !== undefined)) bail("E_BAD_ARGS", "--work directory owns only <home>/work; --work-dir and --branch are not allowed");
2395
1427
  };
2396
- checkDirectoryOptions(requestedWork); // before a local soul could be upserted
1428
+ checkDirectoryOptions(requestedWork); // before anything is resolved or written
2397
1429
  const name = args[1];
2398
- if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--preview] [--base <ref>] [--model <id>|@native-default] [--allow-child-spawns|--no-child-spawns] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
1430
+ if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>|--name <slug>] [--preview] [--base <ref>] [--model <id>|@native-default] [--allow-child-spawns|--no-child-spawns] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--no-launch] [--json]");
2399
1431
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
2400
- // ANY side effect — including root discovery and local-agent upsert (an
2401
- // --instructions-file spawn must not scaffold/overwrite a local soul before
2402
- // this rejection; reviewer-b671de0).
2403
- if (args.includes("--instance")) bail("E_BAD_ARGS", "--instance was removed by the runtime-boundary ruling — use --purpose <slug> (deterministic <agent>-<purpose> naming)");
1432
+ // ANY side effect, including root discovery.
1433
+ // Local souls (local-agents/) are gone with the workspace model: a soul is a member
1434
+ // repository's souls/<name>, so spawn never writes one.
1435
+ for (const removed of ["instructions-file", "def-file"]) if (flag(removed) !== undefined) bail("E_BAD_ARGS", `--${removed} created a local soul under local-agents/, which the workspace model removed — author souls/<name>/soul.yaml + AGENTS.md in a member repository, then \`oats sync\``);
1436
+ if (args.includes("--instance")) bail("E_BAD_ARGS", "--instance was removed by the runtime-boundary ruling — use --purpose <slug> (deterministic <agent>-<purpose> naming) or --name <slug> (the exact instance name)");
1437
+ // --name <slug>: the exact, unprefixed instance name (human decision 2026-09-24).
1438
+ const nameFlag = flag("name");
1439
+ if (nameFlag === true) bail("E_BAD_ARGS", "--name needs an instance name (a slug: lowercase letters and digits, single dashes between them)");
1440
+ if (nameFlag !== undefined && flag("purpose") !== undefined) bail("E_BAD_ARGS", "--name and --purpose are mutually exclusive: --name is the exact instance name, --purpose derives <agent>-<purpose>");
1441
+ // The slug rule is checked here too, before any side effect (soul fetch).
1442
+ if (nameFlag !== undefined) { try { explicitInstanceName(String(nameFlag)); } catch (e) { bail(e.code, e.message); throw e; } }
2404
1443
  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");
2405
1444
  let root;
1445
+ // A spawn needs a workspace deployment (lead decision c3-1): no oats-local.yaml
1446
+ // in reach is E_LOCAL_MISSING, before anything else is read. The agents root is
1447
+ // then <deployment>/agents; an ambient root (the invoking agent's own
1448
+ // PI_AGENTS_ROOT / OATS_ROOT) never redirects it — a home outside
1449
+ // <deployment>/agents would have no derivable deployment.
1450
+ try { loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") bail("E_LOCAL_MISSING", `${e.message}: a spawn needs a workspace deployment — \`oats onboard\` creates one`, e.details); else bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
1451
+ delete process.env.PI_AGENTS_ROOT; delete process.env.OATS_ROOT;
1452
+ // A deployment without its agents/ root is E_NO_DEPLOYMENT, naming the remedy.
2406
1453
  try { root = ensureRoot(dirFlag()); }
2407
1454
  catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
2408
1455
  const isPreview = args.includes("--preview");
2409
1456
  // --agents-root <abs>: the exact root the soul must live in (as inspect and
2410
- // readiness take it). With it, no team-soul / capability-agent / importable-
2411
- // def fallback: the soul is there or the spawn refuses E_SOUL_UNKNOWN.
1457
+ // readiness take it) — the deployment's one agents root, or E_SOUL_UNKNOWN.
2412
1458
  const agentsRootFlag = flag("agents-root");
2413
1459
  if (agentsRootFlag !== undefined && (agentsRootFlag === true || !isAbsolute(String(agentsRootFlag)))) bail("E_BAD_ARGS", "--agents-root needs an absolute agents root");
2414
- if (agentsRootFlag !== undefined && realOrResolved(String(agentsRootFlag)) !== realOrResolved(root)) {
2415
- const teamHit = findTeamAgent(dirFlag(), name), hit = (teamHit?.matches || []).find((m) => realOrResolved(m.root) === realOrResolved(String(agentsRootFlag)));
2416
- if (!hit) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)} (this scope's root is ${shortPath(root)})`);
2417
- root = hit.root;
2418
- }
1460
+ if (agentsRootFlag !== undefined && realOrResolved(String(agentsRootFlag)) !== realOrResolved(root)) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)} (this scope's root is ${shortPath(root)})`);
2419
1461
  let agent = findAgent(root, name);
2420
1462
  // Workspace model: with an oats-local.yaml the soul is ALWAYS discovered over the
2421
- // remotes and resolved (member = latest state, package = locked+approved) — never
1463
+ // remotes and resolved (member = latest state, package = locked) — never
2422
1464
  // "whatever <agents-root>/<name>/soul/ happens to hold": that copy is a per-commit
2423
1465
  // cache (ensureWorkspaceSoul refreshes it when the member moved), so a second
2424
1466
  // spawn sees the member's CURRENT soul, not the first spawn's. A preview runs the
2425
- // same read-only discovery+resolution; the fetched soul copy it may leave under
2426
- // <agents-root>/<name>/soul/ is not an instance (reported as soulFetched).
1467
+ // same read-only discovery+resolution and writes nothing: it reads the soul from
1468
+ // the per-commit cache, or fetches it to a temporary copy (reported as soulFetched).
2427
1469
  const providerPairs = [];
2428
1470
  for (let i = 0; i < args.length; i++) if (args[i] === "--provider") { if (!args[i + 1] || !args[i + 2]) bail("E_BAD_ARGS", "--provider needs <capability> <key>=<value>"); providerPairs.push([args[i + 1], args[i + 2]]); i += 2; }
2429
- let wsPrepared, soulFetched = false, wsSoulUnknown = null;
2430
- let hasLocal = true;
1471
+ let wsPrepared, soulFetched = false, wsSoulUnknown = null, wsDiscovery;
2431
1472
  {
2432
- try { loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") hasLocal = false; else bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
2433
- if (hasLocal) {
2434
- let discovery = null;
2435
- try {
2436
- const { prepareInstance, ensureWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
2437
- const remoteOptions = remoteOptionsFromEnv();
2438
- const { local } = loadLocal(dirFlag());
2439
- discovery = await discoverOrStandalone(local, { remoteOptions });
2440
- wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
2441
- const soulName = wsPrepared.soulEntry.name;
1473
+ let discovery = null;
1474
+ try {
1475
+ const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1476
+ const remoteOptions = remoteOptionsFromEnv();
1477
+ const { local } = loadLocal(dirFlag());
1478
+ discovery = wsDiscovery = await discoverOrStandalone(local, { remoteOptions });
1479
+ wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
1480
+ const soulName = wsPrepared.soulEntry.name;
1481
+ let soulDir;
1482
+ if (isPreview) {
1483
+ // A preview writes nothing in the deployment: the soul comes from the
1484
+ // per-commit cache when complete, else from a temporary fetch removed at exit.
1485
+ const pv = await previewWorkspaceSoul(wsPrepared, root);
1486
+ process.once("exit", pv.cleanup);
1487
+ soulDir = pv.soulDir; soulFetched = pv.fetched;
1488
+ agent = findAgentAt(root, soulName, soulDir);
1489
+ } else {
2442
1490
  const stampFile = join(root, soulName, ".oats-soul-source.json");
2443
1491
  const stampBefore = (() => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } })();
2444
- const soulDir = await ensureWorkspaceSoul(wsPrepared, root);
1492
+ soulDir = await ensureWorkspaceSoul(wsPrepared, root);
2445
1493
  soulFetched = !stampBefore || stampBefore.commit !== wsPrepared.soulEntry.commit || stampBefore.repoKey !== wsPrepared.soulEntry.repoKey;
2446
1494
  if (!agent || soulFetched || agent._dir !== dirname(soulDir)) agent = findAgent(root, soulName);
2447
- if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
2448
- note(`(workspace soul: "${name}" from ${wsPrepared.soulEntry.repoKey} @ ${String(wsPrepared.soulEntry.commit).slice(0, 12)}${wsPrepared.soulEntry.team ? `, team ${wsPrepared.soulEntry.team}` : ""}${soulFetched ? "; soul source fetched" : ""})`);
2449
- } catch (e) {
2450
- // Standalone (decisions 10/25): the ONLY package request is the kernel's own
2451
- // default; when the catalog cannot name it, say so instead of "add it to packages:"
2452
- // (there is no workspace file to add it to).
2453
- if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
2454
- let file = process.env.OATS_PACKAGE_CATALOG || null; try { file = describeOfficialCatalog().catalog.file; } catch { /* keep the env value */ }
2455
- bail(e.code, `${e.details?.capability ?? "oats.core"}: the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}) — standalone spawns resolve only the kernel's default package from the catalog`, { ...(e.details ?? {}), standalone: true, reason: "no-catalog", catalog: file });
2456
- }
2457
- // Not a workspace soul: a capability-defined agent (a module's `agents:`
2458
- // soul, resolved below from a materialized copy) or a local-only soul
2459
- // (--instructions-file/--def-file) may still answer to this name.
2460
- if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
2461
- else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
2462
- else throw e;
2463
1495
  }
2464
- } else if (providerPairs.length) bail("E_BAD_ARGS", "--provider needs a workspace deployment (oats-local.yaml); this directory has none");
1496
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
1497
+ note(`(workspace soul: "${name}" from ${wsPrepared.soulEntry.repoKey} @ ${String(wsPrepared.soulEntry.commit).slice(0, 12)}${wsPrepared.soulEntry.team ? `, team ${wsPrepared.soulEntry.team}` : ""}${soulFetched ? "; soul source fetched" : ""})`);
1498
+ } catch (e) {
1499
+ // Standalone (decisions 10/25): the ONLY package request is the kernel's own
1500
+ // default; when the catalog cannot name it, say so instead of "add it to packages:"
1501
+ // (there is no workspace file to add it to).
1502
+ if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
1503
+ const file = officialCatalogFile();
1504
+ bail(e.code, `${e.details?.capability ?? "oats.core"}: the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file}) — standalone spawns resolve only the kernel's default package from the catalog`, { ...(e.details ?? {}), standalone: true, reason: "no-catalog", catalog: file });
1505
+ }
1506
+ // Not a workspace soul: a capability-defined agent (a module's `agents:`
1507
+ // soul, resolved below from a materialized copy) may still answer to this name.
1508
+ if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
1509
+ else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
1510
+ else throw e;
1511
+ }
2465
1512
  }
2466
1513
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
2467
1514
  if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
2468
- if (isPreview && (flag("instructions-file") !== undefined || flag("def-file") !== undefined)) bail("E_BAD_ARGS", "--preview does not take --instructions-file/--def-file: a preview never writes a soul");
2469
- const instrFile = flag("instructions-file");
2470
- const defFile = flag("def-file");
2471
- if (!agent && !instrFile && !defFile) {
2472
- // Capability-defined agent: a package's `agents:` soul, active in this context.
2473
- const capAgent = findCapabilityAgent(dirFlag(), root, name);
2474
- if (capAgent) {
2475
- agent = capAgent;
2476
- note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
2477
- }
2478
- }
2479
- if (!agent && !instrFile && !defFile && hasLocal) {
1515
+ // Capability agent (lead decision c3 Q1): a prepared spawn of its providing module only.
1516
+ let capabilityPrepared, capabilityPkg = null;
1517
+ if (!agent) {
2480
1518
  // Workspace model: the agent is declared by a capability some INSTANCE already
2481
1519
  // materialized (the --parent home first, then any home under this root) —
2482
1520
  // OKF's memory-harvest worker spawned by a knowledge source, for example.
@@ -2487,50 +1525,30 @@ async function spawnCmd() {
2487
1525
  catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
2488
1526
  if (modAgent) {
2489
1527
  agent = modAgent;
2490
- note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home locally)`);
1528
+ note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`);
2491
1529
  } else {
2492
- // No instance carries it: resolve from the deployment's LOCK — an approved
1530
+ // No instance carries it: resolve from the deployment's LOCK — a locked
2493
1531
  // package whose capability declares agents/<name> is fetched into the
2494
1532
  // deployment's module store and read from there.
2495
1533
  try {
2496
1534
  const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
2497
- const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1535
+ const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), discovery: wsDiscovery, catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
2498
1536
  if (hit) {
2499
1537
  agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
2500
- if (agent) note(`(capability agent: "${name}" from ${hit.capability} — package ${hit.package} v${hit.version}, fetched to ${shortPath(hit.dir)} — fresh soul, instances home locally)`);
1538
+ if (agent) { capabilityPkg = hit; note(`(capability agent: "${name}" from ${hit.capability} — package ${hit.package} v${hit.version}, fetched to ${shortPath(hit.dir)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`); }
2501
1539
  }
2502
1540
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2503
1541
  }
2504
- }
2505
- if (!agent && wsSoulUnknown && !instrFile && !defFile) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
2506
- if (!agent && !instrFile && !defFile) {
2507
- // Cross-repo lookup: the soul may live in a sibling repo of the team scope.
2508
- // Unique match wins; the instance homes with its owning repo's agents root.
2509
- const teamHit = findTeamAgent(dirFlag(), name);
2510
- const remote = (teamHit?.matches || []).filter((m) => resolve(m.root) !== resolve(root));
2511
- 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>`);
2512
- if (remote.length === 1) {
2513
- root = remote[0].root;
2514
- agent = remote[0].agent;
2515
- note(`(cross-repo: soul "${name}" found at ${shortPath(root)} — instance homes there)`);
1542
+ if (agent) {
1543
+ try {
1544
+ const { prepareCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1545
+ capabilityPrepared = await prepareCapabilityAgent(dirFlag(), agent, { discovery: wsDiscovery, pkg: capabilityPkg, remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1546
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2516
1547
  }
2517
1548
  }
1549
+ if (!agent && wsSoulUnknown) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
2518
1550
  checkDirectoryOptions(requestedWork || agent?.work);
2519
- // local agents: create/update from raw instructions or a single-file def
2520
- if (instrFile || defFile || !agent) {
2521
- if (!agent && !instrFile && !defFile) {
2522
- const def = listAgentDefs(process.cwd()).find((d) => d.name === name);
2523
- 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`);
2524
- agent = upsertLocalAgent(root, { name: def.name, file: def.path, repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"), oatsCore: !args.includes("--no-oats-core") });
2525
- } else if (!agent || agent.kind === "local") {
2526
- agent = upsertLocalAgent(root, {
2527
- name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined, oatsCore: !args.includes("--no-oats-core"),
2528
- repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo: yoloFlag(),
2529
- });
2530
- } else {
2531
- bail("E_BAD_ARGS", `"${name}" is a persistent agent — spawn it without --instructions-file/--def-file`);
2532
- }
2533
- }
1551
+ if (!agent) bail("E_UNKNOWN_AGENT", `unknown agent "${name}" (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"}) — a soul is a member repository's souls/<name>; add it there and run \`oats sync\``);
2534
1552
  for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
2535
1553
  // Lineage is explicit: --relation child|sibling|parent|unrelated anchors the new
2536
1554
  // instance to --relative-to <instance>. --parent X is sugar for
@@ -2558,9 +1576,9 @@ async function spawnCmd() {
2558
1576
  // NOTE: explicit "unrelated" is passed through to the kernel.
2559
1577
  if (relativeTo && relation !== "unrelated") {
2560
1578
  // findInstanceHome also sees capability-defined agents' instance homes
2561
- // (local-agents/<name>/ without a local soul) — e.g. a reviewer passing
1579
+ // (<root>/<name>/ without a soul) — e.g. a reviewer passing
2562
1580
  // --parent "$OATS_INSTANCE" from a capability agent.
2563
- 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`);
1581
+ if (!findInstanceHome(root, relativeTo)) bail(parent ? "E_PARENT_NOT_FOUND" : "E_RELATIVE_NOT_FOUND", `${parent ? "--parent" : "--relative-to"} "${relativeTo}" does not match any known instance`);
2564
1582
  }
2565
1583
  const taskText = flag("task");
2566
1584
  if (taskText === true) bail("E_BAD_ARGS", "--task needs a value (use --task-file for long tasks)");
@@ -2600,7 +1618,7 @@ async function spawnCmd() {
2600
1618
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2601
1619
  // Workspace model: when this deployment has an oats-local.yaml, the soul's
2602
1620
  // capabilities are resolved over the workspace's remotes (member = latest,
2603
- // package = locked+approved) and copied whole into the new home. Without one
1621
+ // package = locked) and copied whole into the new home. Without one
2604
1622
  // (a bare agents root, tests) the classic soul-directory spawn proceeds.
2605
1623
  let prepared;
2606
1624
  // Workspace model: a `work: worktree|checkout` soul works IN its member's clone on
@@ -2608,10 +1626,10 @@ async function spawnCmd() {
2608
1626
  // convention <deployment>/<member name>. Never an ambient Git checkout around the
2609
1627
  // deployment. Resolved once here so a preview sees exactly what the apply would.
2610
1628
  let preparedRepo;
2611
- if (wsPrepared) {
1629
+ if (wsPrepared || capabilityPrepared) {
2612
1630
  try {
2613
1631
  const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
2614
- prepared = wsPrepared;
1632
+ prepared = wsPrepared ?? capabilityPrepared;
2615
1633
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
2616
1634
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
2617
1635
  prepared.toCapabilityRows = toCapabilityRows;
@@ -2625,12 +1643,13 @@ async function spawnCmd() {
2625
1643
  if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
2626
1644
  { const spawnOpts = {
2627
1645
  prepared,
2628
- purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
1646
+ purpose: flag("purpose"), ...(nameFlag !== undefined ? { name: String(nameFlag) } : {}), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
2629
1647
  ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
2630
1648
  // Directory execution uses deployment configuration, not an ambient Git
2631
1649
  // checkout (especially when invoked via --dir from a source instance).
2632
- repo: preparedRepo !== undefined ? preparedRepo : (requestedWork || agent.work) === "directory"
2633
- ? (repo ?? agent.repo) : repo || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
1650
+ // An attached instance's repository is its work tree owner's (derived by the kernel).
1651
+ repo: preparedRepo !== undefined ? preparedRepo : ["directory", "attached"].includes(requestedWork || agent.work)
1652
+ ? repo : repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2634
1653
  work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
2635
1654
  launchConfig: valueFlag("launch-config"),
2636
1655
  launch: !args.includes("--no-launch"),
@@ -2644,13 +1663,13 @@ async function spawnCmd() {
2644
1663
  // K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
2645
1664
  ...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
2646
1665
  };
2647
- r = prepared ? await spawnInstanceAsync(root, agent, spawnOpts) : spawnInstance(root, agent, spawnOpts); }
1666
+ r = await spawnInstanceAsync(root, agent, spawnOpts); }
2648
1667
  if (args.includes("--preview")) {
2649
- // A workspace preview may have fetched the soul's SOURCE under <agents-root>/<name>/soul/
2650
- // (a per-commit cache, not an instance): the result says so.
1668
+ // A workspace preview may have fetched the soul's SOURCE to a temporary copy
1669
+ // (the deployment's cache had no entry for its commit): the result says so.
2651
1670
  if (prepared) r.soulFetched = soulFetched;
2652
1671
  if (JSON_MODE) { jsonOk(r); return; }
2653
- console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? ` (the soul source was fetched to ${shortPath(join(root, r.agent, "soul"))} — a per-commit copy, not an instance)` : ""}`);
1672
+ console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? " (the soul source was fetched to a temporary copy, not kept)" : ""}`);
2654
1673
  return;
2655
1674
  }
2656
1675
  } catch (e) {
@@ -2672,6 +1691,8 @@ async function spawnCmd() {
2672
1691
  if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
2673
1692
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
2674
1693
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1694
+ if (e?.code === "E_INSTANCE_NAME_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home ?? null, ...(e.session ? { session: e.session } : {}) }); throw e; }
1695
+ if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message); throw e; }
2675
1696
  if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
2676
1697
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
2677
1698
  }
@@ -2734,7 +1755,7 @@ function retireCmd() {
2734
1755
  console.log(` defaults: retain worktree ${plan.defaults.retainWorktree}, delete branch ${plan.defaults.deleteBranch}, stop children ${plan.defaults.stopChildren}`);
2735
1756
  for (const n of plan.notes) console.log(` note: ${n}`);
2736
1757
  return;
2737
- } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined) : die(e.message); }
1758
+ } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
2738
1759
  }
2739
1760
  // The calling instance knows its own home: self-retire never needs to
2740
1761
  // disambiguate a same-named twin by hand.
@@ -2742,13 +1763,7 @@ function retireCmd() {
2742
1763
  const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
2743
1764
  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)`);
2744
1765
  if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
2745
- let root = ensureRoot(dirFlag());
2746
- // Cross-repo: the instance may home in a sibling repo of the team scope.
2747
- if (!listAgents(root).some((a) => existsSync(join(a._dir, "instances", name)))) {
2748
- const hit = findTeamInstance(dirFlag(), name);
2749
- // Stdout carries only the envelope in JSON mode (the Desktop parses it).
2750
- if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; (args.includes("--json") ? console.error : console.log)(`(cross-repo: instance homes at ${shortPath(root)})`); }
2751
- }
1766
+ const root = ensureRoot(dirFlag());
2752
1767
  const retiringHome = homeFlag || findInstanceHome(root, name);
2753
1768
  // K3: a GUI-driven Remove carries the plan revision it showed and an
2754
1769
  // idempotency key. The revision is revalidated against a fresh plan
@@ -2765,7 +1780,7 @@ function retireCmd() {
2765
1780
  const replay = (dir) => { const p = join(dir, `.oats-retire-receipt.${idemKey}.json`); if (!existsSync(p)) return false; try { const prior = JSON.parse(readFileSync(p, "utf8")); if (prior.retired !== name) return false; if (args.includes("--json")) jsonOk({ ...prior, replayed: true }); else console.log(`retire ${name}: replayed receipt for key ${idemKey}`); return true; } catch { return false; } };
2766
1781
  for (const a of listAgents(root)) if (replay(join(a._dir, "instances"))) return;
2767
1782
  let fresh;
2768
- try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message) : die(e.message); }
1783
+ try { fresh = planRetire(dirFlag(), root, name, { home: homeFlag }); } catch (e) { return args.includes("--json") ? jsonFail(e.code || "E_LIFECYCLE_FAILED", e.message, e.details) : die(e.message); }
2769
1784
  replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
2770
1785
  if (fresh.planRevision !== planRev) return args.includes("--json") ? jsonFail("E_PLAN_STALE", `the retire plan changed since it was shown (${planRev} → ${fresh.planRevision}); review the fresh plan`, { plan: fresh }) : die(`the retire plan changed since it was shown; re-run oats retire ${name} --plan`);
2771
1786
  // The plan promised: recorded children are STOPPED first (bounded, never
@@ -2780,7 +1795,9 @@ function retireCmd() {
2780
1795
  if (running.length) return args.includes("--json") ? jsonFail("E_CHILDREN_RUNNING", `${running.map((k) => k.instance).join(", ")} ${running.length === 1 ? "is" : "are"} still running after a bounded stop; nothing was retired and nothing was escalated`, { childrenStopped, plan: fresh }) : die(`children still running: ${running.map((k) => k.instance).join(", ")}; nothing retired`);
2781
1796
  expectedBranch = fresh.facts.work.observed ? fresh.facts.work.branch : undefined;
2782
1797
  }
2783
- const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(expectedBranch !== undefined ? { expectedBranch } : {}) });
1798
+ let r;
1799
+ try { r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), discardWorktree: args.includes("--discard-worktree"), keepDir: args.includes("--keep-dir"), force: args.includes("--force"), ...(expectedBranch !== undefined ? { expectedBranch } : {}) }); }
1800
+ catch (e) { if (!e?.code) throw e; return args.includes("--json") ? jsonFail(e.code, e.message, e.candidates ? { ...e.details, candidates: e.candidates } : e.details) : die(e.message); }
2784
1801
  if (childrenStopped) r.childrenStopped = childrenStopped;
2785
1802
  if (replayPath) { r.planRevision = planRev; r.idempotencyKey = idemKey; r.replayed = false; try { writeFileAtomic(replayPath, JSON.stringify(r, null, 2)); } catch { /* receipt is evidence, not authority */ } }
2786
1803
  // A retired home's wake jobs are forgotten (definitions only; nothing is
@@ -2818,8 +1835,10 @@ function retireCmd() {
2818
1835
  // which is most of the harm of deleting it. Name the classes and the path.
2819
1836
  for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
2820
1837
  console.log(`Work that was not committed has been preserved: ${recovery.classes.join(", ")}`);
2821
- console.log(` ${recovery.path}`);
1838
+ console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
1839
+ for (const line of preservedOutputLines(recovery)) console.log(line);
2822
1840
  }
1841
+ for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2823
1842
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2824
1843
  }
2825
1844
 
@@ -2829,9 +1848,11 @@ function retireCmd() {
2829
1848
  function scheduleCmd() {
2830
1849
  const sub = args[1];
2831
1850
  const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
2832
- // One schedule-owning scope for a directory: the team workspace (the
2833
- // config level declaring the team), else the outermost config level.
2834
- const ws = scheduleScopeOf(dirFlag());
1851
+ // One schedule-owning scope for a directory: its deployment (the directory
1852
+ // holding oats-local.yaml), resolved when a subcommand needs it — inside the
1853
+ // try, so no deployment in reach is a typed refusal (E_LOCAL_MISSING).
1854
+ let scope;
1855
+ const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
2835
1856
  const io = { hostStatus: () => hostUnitStatus() };
2836
1857
  const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2837
1858
  const readSpec = () => {
@@ -2845,41 +1866,41 @@ function scheduleCmd() {
2845
1866
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
2846
1867
  try {
2847
1868
  switch (sub) {
2848
- case "list": return out(listSchedules(ws, io));
2849
- case "show": return out({ schedule: describeSchedule(ws, needId(), io) });
2850
- case "add": { const spec = readSpec(); if (id && spec.id === undefined) spec.id = id; if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" }); return out({ schedule: addSchedule(ws, spec, io) }); }
2851
- case "update": return out({ schedule: updateSchedule(ws, needId(), readSpec(), io) });
2852
- case "enable": return out({ schedule: setScheduleEnabled(ws, needId(), true, io) });
2853
- case "disable": return out({ schedule: setScheduleEnabled(ws, needId(), false, io) });
2854
- case "run": return out(runScheduleNow(ws, needId(), { io, force: args.includes("--force") }));
2855
- case "remove": return out(removeSchedule(ws, needId(), { force: args.includes("--force") }));
2856
- case "reconcile": return out(reconcileSchedule(ws, needId(), { io, clear: args.includes("--clear") }));
1869
+ case "list": return out(listSchedules(ws(), io));
1870
+ case "show": return out({ schedule: describeSchedule(ws(), needId(), io) });
1871
+ case "add": { const spec = readSpec(); if (id && spec.id === undefined) spec.id = id; if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" }); return out({ schedule: addSchedule(ws(), spec, io) }); }
1872
+ case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
1873
+ case "enable": return out({ schedule: setScheduleEnabled(ws(), needId(), true, io) });
1874
+ case "disable": return out({ schedule: setScheduleEnabled(ws(), needId(), false, io) });
1875
+ case "run": return out(runScheduleNow(ws(), needId(), { io, force: args.includes("--force") }));
1876
+ case "remove": return out(removeSchedule(ws(), needId(), { force: args.includes("--force") }));
1877
+ case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear") }));
2857
1878
  case "tick": {
2858
1879
  const dryRun = args.includes("--dry-run");
2859
1880
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
2860
1881
  const reg = readRegistry();
2861
- const considered = withHostLock(() => tickWorkspace(ws, { io, reg, wsList: reg.workspaces.includes(ws) ? reg.workspaces : [...reg.workspaces, ws], dryRun }));
2862
- return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws, io) });
1882
+ const considered = withHostLock(() => tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun }));
1883
+ return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
2863
1884
  }
2864
1885
  case "host": {
2865
1886
  const op = args[2];
2866
- if (op === "install") { registerWorkspace(ws); installHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2867
- if (op === "uninstall") { unregisterWorkspace(ws); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2868
- if (op === "status") return out({ scheduler: schedulerStatus(ws, io) });
1887
+ if (op === "install") { registerWorkspace(ws()); installHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
1888
+ if (op === "uninstall") { unregisterWorkspace(ws()); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
1889
+ if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
2869
1890
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2870
1891
  }
2871
1892
  default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2872
1893
  }
2873
1894
  } catch (e) {
2874
1895
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
2875
- const details = Object.fromEntries(["key", "declared", "source", "field"].filter((k) => e[k] !== undefined).map((k) => [k, e[k]]));
1896
+ const details = { ...(e.details && typeof e.details === "object" ? e.details : {}), ...Object.fromEntries(["key", "declared", "source", "field"].filter((k) => e[k] !== undefined).map((k) => [k, e[k]])) };
2876
1897
  if (JSON_MODE) jsonFail(e.code || "E_SCHEDULE_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
2877
1898
  }
2878
1899
  }
2879
1900
 
2880
1901
  async function sessionCmd() {
2881
1902
  try {
2882
- if (flag("native-record") !== undefined) throw Object.assign(new Error("--native-record outcome inspection requires exact captured deployment/resolution selectors"), { code: "E_BAD_ARGS" });
1903
+ if (flag("native-record") !== undefined) throw Object.assign(new Error("--native-record outcome inspection is gone (the captured/portable path was removed in 0.26)"), { code: "E_BAD_ARGS" });
2883
1904
  const home = flag("home");
2884
1905
  let result;
2885
1906
  if (args[1] === "attach") {
@@ -2888,7 +1909,7 @@ async function sessionCmd() {
2888
1909
  return;
2889
1910
  }
2890
1911
  if (args[1] === "inspect") result = inspectInstanceSession(home);
2891
- else if (args[1] === "recompose") result = recomposeInstanceInstructions(home, { dryRun: args.includes("--dry-run") });
1912
+ else if (args[1] === "recompose") throw Object.assign(new Error('unknown command "session recompose" — removed by the workspace model v2; use a re-spawn: an instance never changes under itself'), { code: "E_UNKNOWN_COMMAND" });
2892
1913
  else if (args[1] === "start" || args[1] === "restart") {
2893
1914
  const bad = (msg) => { throw Object.assign(new Error(msg), { code: "E_BAD_ARGS" }); };
2894
1915
  const model = flag("model");
@@ -2897,7 +1918,7 @@ async function sessionCmd() {
2897
1918
  if (launchConfig === true) bad("--launch-config needs a configuration name, or none");
2898
1919
  const runtime = flag("runtime");
2899
1920
  if (runtime === true || (runtime !== undefined && !LAUNCH_RUNTIMES.includes(runtime))) bad(`--runtime must be one of ${LAUNCH_RUNTIMES.join(", ")}`);
2900
- const opts = { model: model || undefined, launchConfig, runtime, yolo: yoloFlag(), env: process.env };
1921
+ const opts = { model: model || undefined, launchConfig, runtime, yolo: yoloFlag(), env: process.env, ...(await homeLiveTeams(home)) };
2901
1922
  if (args[1] === "restart") {
2902
1923
  const grace = flag("stop-grace");
2903
1924
  if (grace !== undefined) { if (grace === true || !/^\d+$/.test(String(grace)) || Number(grace) < 1 || Number(grace) > 300) bad("--stop-grace needs a number of seconds (1..300) to wait for the harness after SIGTERM"); opts.stopGraceMs = Number(grace) * 1000; }
@@ -2922,7 +1943,7 @@ async function sessionCmd() {
2922
1943
  result = uploadAttachment({ file, home: home === true ? undefined : home });
2923
1944
  } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start|restart|receive|upload --home /absolute/home [--text-file path] [--model id] [--launch-config name|none] [--runtime pi|claude|codex] [--yolo|--no-yolo] [--stop-grace seconds] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
2924
1945
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2925
- } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
1946
+ } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message, e.details); }
2926
1947
  }
2927
1948
 
2928
1949
  async function paneCmd() {
@@ -2935,8 +1956,7 @@ async function paneCmd() {
2935
1956
  * (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
2936
1957
  * `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
2937
1958
  * instance homes), then runs exactly the `oats sync` path — discover over the
2938
- * remotes, confirm membership, resolve `packages:`, approve (TTY) or list what
2939
- * needs approval (exit 2), write `oats-lock.json`. Nothing is installed, no soul
1959
+ * remotes, confirm membership, resolve `packages:`, write `oats-lock.json`. Nothing is installed, no soul
2940
1960
  * is created, nothing is spawned, no `oats-config.yaml` is written: the member
2941
1961
  * clones and the operator expert are the operator's next steps, printed here. */
2942
1962
  async function onboardCmd() {
@@ -3043,14 +2063,14 @@ async function onboardCmd() {
3043
2063
  const hostIsMember = members.some((m) => m.key === synced.discovery.key);
3044
2064
  const hosting = { host: synced.discovery.key, hostIsMember, rule: "If any member is private, host oats-workspace.yaml in a private repo that is not a public member (a dedicated <org>/workspace repo); public contributors then use the standalone case (from: here capabilities + oats.core)." };
3045
2065
  const result = { onboardApi: 2, standalone: standalone || undefined, local: localFile, dir, agents: join(dir, "agents"), lock: synced.lockFile, sync: synced.report, hosting, next: { clone: clones, spawn: spawnHint, souls: soulNames.slice(0, 3) } };
3046
- if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
2066
+ if (JSON_MODE) { jsonOk(result); return; }
3047
2067
 
3048
2068
  console.log(`Onboarded ${shortPath(dir)} into workspace ${workspaceName(synced.discovery)} (${synced.discovery.key} @ ${short(synced.discovery.commit)}).${standalone ? `\n (${standaloneNote(synced.discovery, { from: " from here" })})` : ""}\n`);
3049
2069
  printSyncReport(ctx, synced);
3050
2070
  console.log(`
3051
2071
  This directory (${shortPath(dir)}) is your deployment — any layout works; it now holds what the kernel needs:
3052
2072
  ├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
3053
- ├── oats-lock.json exact commit + integrity + per-version executable approval per package
2073
+ ├── oats-lock.json exact commit + integrity per package
3054
2074
  └── agents/ instance homes, each self-contained
3055
2075
  Member clones live wherever you keep them (here, or anywhere named in oats-local.yaml clones:).
3056
2076
 
@@ -3060,9 +2080,8 @@ Next:
3060
2080
  2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
3061
2081
  names every member, so if any member is private the host must be a private repo that is not
3062
2082
  a public member; public contributors then get the standalone case (from: here + oats.core).
3063
- 3. ${setupExpert ? "Spawn the operator expert to guide the rest (souls, teams, provider settings, approvals):" : "No soul named oats-operator-expert is listed here —"}
3064
- ${spawnHint ?? anySoulHint}${synced.approvalNeeded.length ? `\n (first: \`oats sync --dir ${shortPath(dir)}\` in a terminal to approve ${synced.approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")})` : ""}`);
3065
- process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2083
+ 3. ${setupExpert ? "Spawn the operator expert to guide the rest (souls, teams, provider settings):" : "No soul named oats-operator-expert is listed here —"}
2084
+ ${spawnHint ?? anySoulHint}`);
3066
2085
  }
3067
2086
 
3068
2087
  /** The clone URL of a member row: what the remote observed (from the workspace's members: refs;
@@ -3075,45 +2094,6 @@ function memberUrlOf(discovery, key) {
3075
2094
  return null;
3076
2095
  }
3077
2096
 
3078
- function createCmd() {
3079
- const yolo = yoloFlag();
3080
- const name = args[1];
3081
- if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--no-oats-core] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
3082
- const local = args.includes("--local");
3083
- const startDir = dirFlag();
3084
- // `create` BOOTSTRAPS a deployment: with no agents/ or local-agents/ yet,
3085
- // anchor at the enclosing git repo (else the start dir). It is the command
3086
- // that populates the roster root, so it must not demand that the root
3087
- // already exist — that demand was the first thing a new user hit after
3088
- // `oats init` (a raw stack trace from ensureRoot). Local and committed souls
3089
- // anchor the same way; writeSoul creates the directories.
3090
- let root = findRoot(startDir);
3091
- // A configured package-only scope may resolve its future agents root before
3092
- // that directory exists. Preserve create's bootstrap receipt/message.
3093
- let bootstrapped = !!root && !existsSync(root) && !existsSync(join(dirname(root), "local-agents"));
3094
- if (!root) {
3095
- root = join(defaultRepo(startDir) || resolve(startDir), "agents");
3096
- bootstrapped = true;
3097
- }
3098
- const instrFile = flag("instructions-file");
3099
- const r = coreCreateAgent(root, {
3100
- name, local, oatsCore: !args.includes("--no-oats-core"), description: flag("description"), type: flag("type"), repo: flag("repo") || (flag("work") === "directory" ? undefined : defaultRepo(process.cwd())),
3101
- work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo,
3102
- instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
3103
- });
3104
- // A declared oats.core is a requirement spawn will enforce: say the next
3105
- // step here, not only at the refusal.
3106
- const declared = r.declaredCapabilities || [];
3107
- const inactive = declared.filter((id) => !(resolveOatsConfig(workspaceOf(root), name).capabilities || []).some((c) => c.id === id));
3108
- const next = inactive.length ? [{ code: "next-step", message: `${name} declares ${inactive.join(", ")}; before spawning, pin their packages in oats-workspace.yaml packages: and declare them in soul.yaml capabilities: { <cap>: { from } } (workspace model v2)` }] : [];
3109
- const notes = [...(r.notes || []), ...next];
3110
- if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(notes.length ? { notes } : {}), ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
3111
- for (const information of notes) console.error(`[${information.code}] ${information.message}`);
3112
- if (bootstrapped) console.log(`Created deployment root ${shortPath(root)} (this scope had no agents/ yet)`);
3113
- console.log(`Created ${r.kind === "local" ? "LOCAL agent (uncommitted — soul lives in local-agents/, gitignored)" : "agent"} "${r.agent}" — soul at ${shortPath(r.soul)}`);
3114
- console.log(`Edit ${shortPath(join(r.soul, "AGENTS.md"))} to define its role, then${inactive.length ? ` activate ${inactive.join(", ")} (above) and` : ":"} oats spawn ${r.agent} --task "..."`);
3115
- }
3116
-
3117
2097
  // ---------- capability command dispatch ----------
3118
2098
  /**
3119
2099
  * oats <namespace> <command> [args…] — run a command an active capability
@@ -3127,7 +2107,7 @@ function createCmd() {
3127
2107
  * namespace's capability into <deployment>/.oats/modules/<cap>@<commit12>/
3128
2108
  * and run THAT copy with the soul's merged payload (lib/operator-dispatch.mjs;
3129
2109
  * contracts doc, "Post-0.25.0 clarifications");
3130
- * - otherwise the classic config chain.
2110
+ * - otherwise no namespace is active (the help fallthrough answers).
3131
2111
  */
3132
2112
  async function capabilityCommand() {
3133
2113
  // JSON-aware boundary: in --json mode every dispatch failure — inactive or
@@ -3159,64 +2139,79 @@ async function capabilityCommand() {
3159
2139
  throw e;
3160
2140
  }
3161
2141
  if (!hit) return NOT_DISPATCHED;
3162
- const teamCtx = hit.soul?.team ? { name: hit.soul.team } : undefined;
3163
- return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree);
2142
+ // The same team/workspace facts a spawn hook receives (lead decision c3-7).
2143
+ const teamCtx = teamEnv(resolvedFromPrepared(hit.prepared, hit.deployment));
2144
+ // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2145
+ // already fetched exactly this commit; otherwise the command gets none (never ambient).
2146
+ const cachedSoul = hit.soul?.commit ? join(hit.deployment, "agents", hit.soul.name, "souls", String(hit.soul.commit).slice(0, 12)) : null;
2147
+ return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
3164
2148
  }
3165
2149
 
3166
2150
  async function dispatch() {
3167
2151
  let activeIds;
3168
2152
  let context = process.cwd();
3169
- let teamCtx;
3170
- const instanceHome = process.env.PI_AGENT_HOME || process.env.OATS_HOME;
2153
+ let teamCtx, homeMeta, homeTeamCtx;
2154
+ // OATS_INSTANCE_HOME is the canonical identity; the older names still count.
2155
+ const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME;
3171
2156
  const metaFile = instanceHome && join(instanceHome, "instance.json");
3172
2157
  // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
3173
2158
  // braces: the ids come from instance.json, which spawn wrote from resolved
3174
2159
  // manifests. Null-prototype because the dispatcher indexes it with the
3175
2160
  // namespace the operator typed on the command line.
3176
2161
  let capSettings = Object.create(null);
3177
- let instanceModules = false;
3178
2162
  let deployment = null;
2163
+ let soulDir;
3179
2164
  try {
3180
2165
  if (metaFile && existsSync(metaFile)) {
3181
2166
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
3182
- instanceModules = !!(meta.modules && typeof meta.modules === "object");
3183
2167
  activeIds = (meta.capabilities || []).map((c) => c.id);
3184
2168
  for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
2169
+ // Workspace-model homes only (lead decision c3 Q2).
2170
+ if (isCapturedHome(meta)) { const e = capturedHomeRefusal(instanceHome, "nothing was dispatched"); bail(e.code, e.message, e.details); }
2171
+ if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
3185
2172
  context = meta.repo || context;
3186
- // Team: the spawn-time snapshot, but fall back to live config — instances
3187
- // spawned before a team: block was declared have no snapshot.
3188
- teamCtx = meta.team || resolveOatsConfig(context).team;
2173
+ soulDir = instanceSoulDir(instanceHome, meta);
2174
+ // The team/workspace facts the home recorded at spawn, as its hooks got them, with the
2175
+ // recorded eligible teams (OATS_TEAMS_SOURCE=recorded). Only the home's MESSAGING module
2176
+ // gets them live (below): its team verbs (join/leave/teams) must see what the workspace
2177
+ // allows now, and no other command pays a remote read for them.
2178
+ const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
2179
+ const messaging = (meta.capabilities || []).find((c) => c.layer === "messaging")?.id;
2180
+ homeMeta = { meta, messaging };
2181
+ homeTeamCtx = (teams, teamsSource) => teamEnv({ workspace: { key: ws.key, name: ws.name, deployment: ws.deployment, team: ws.soul?.team, slots: { messaging } }, payloads: meta.providers, teams, teamsSource });
2182
+ teamCtx = homeTeamCtx(meta.teams, "recorded");
3189
2183
  } else {
3190
2184
  // Not inside a home: a v2 deployment (oats-local.yaml in reach) resolves
3191
2185
  // through the workspace, exactly as a spawn of --soul would (below).
3192
2186
  try { const { deploymentOf } = await import("../lib/operator-dispatch.mjs"); deployment = deploymentOf(context); }
3193
2187
  catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
3194
- if (!deployment) {
3195
- const resolved = resolveOatsConfig(context, flag("soul"));
3196
- activeIds = resolved.capabilities.map((c) => c.id);
3197
- for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
3198
- teamCtx = resolved.team;
3199
- }
2188
+ // No home and no deployment in reach: no capability namespace is active.
2189
+ if (!deployment) return NOT_DISPATCHED;
3200
2190
  }
3201
2191
  } catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
3202
2192
  if (deployment) return operatorDispatch();
3203
2193
  // Workspace model: an instance's own materialized modules are the command
3204
2194
  // namespaces available to it (instance.json.modules → <home>/.oats/modules).
3205
- const mans = Object.values(capabilityManifests(instanceModules ? instanceHome : context)).filter((m) => m.command === cmd && m.commands);
2195
+ const mans = Object.values(capabilityManifests(instanceHome)).filter((m) => m.command === cmd && m.commands);
3206
2196
  if (!mans.length) return NOT_DISPATCHED;
3207
2197
  if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
3208
2198
  const m = mans[0];
3209
2199
  if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
3210
- const trust = capabilityTrust(m, context);
2200
+ const trust = capabilityTrust(m);
3211
2201
  if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
3212
- return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir);
2202
+ if (homeMeta && m.capability === homeMeta.messaging) {
2203
+ const { liveTeams } = await import("../lib/instance-resolution.mjs");
2204
+ const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2205
+ teamCtx = homeTeamCtx(live.teams, live.source);
2206
+ }
2207
+ return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir, soulDir);
3213
2208
  }
3214
2209
 
3215
2210
  /** Help / unknown-command / spec validation / exec — shared by every context.
3216
2211
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
3217
2212
  * resolves the directory holding the executable — the operator branch fetches
3218
2213
  * the module tree only when a command is actually going to run). */
3219
- async function runManifestCommand(m, settings, teamCtx, ensureDir) {
2214
+ async function runManifestCommand(m, settings, teamCtx, ensureDir, soulDir) {
3220
2215
  const sub = args[1];
3221
2216
  const cmds = Object.keys(m.commands);
3222
2217
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -3250,8 +2245,11 @@ async function capabilityCommand() {
3250
2245
  try { abs = capabilityExecutablePath(withDir, script); }
3251
2246
  catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
3252
2247
  if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(dir, script)})`);
2248
+ // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2249
+ // invoking process names some other soul (a coordinator's own), never this one.
2250
+ const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
3253
2251
  const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
3254
- ...process.env, OATS_CAPABILITY: m.capability,
2252
+ ...inherited, OATS_CAPABILITY: m.capability,
3255
2253
  // Package-runtime boundary: dispatched commands receive the active
3256
2254
  // capability's EFFECTIVE settings (instance snapshot, resolved context, or
3257
2255
  // the soul's merged payload on operator-level dispatch), same contract as
@@ -3262,7 +2260,10 @@ async function capabilityCommand() {
3262
2260
  // canonical absolute executable of THIS CLI; official consumers execFile
3263
2261
  // it directly and never resolve `oats` from PATH or a shell.
3264
2262
  OATS_CLI_BIN: CLI_BIN,
3265
- OATS_TEAM_NAME: teamCtx?.name || "", OATS_TEAM_ID: teamCtx?.id || "", OATS_TEAM_SCOPE: teamCtx?.scope || "",
2263
+ ...teamEnv(null), ...(teamCtx || {}),
2264
+ // The soul the command acts for (an instance home's recorded soul directory):
2265
+ // homes carry no soul link, so providers read it here.
2266
+ ...(soulDir ? { OATS_SOUL: soulDir } : {}),
3266
2267
  } });
3267
2268
  // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
3268
2269
  if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
@@ -3270,52 +2271,6 @@ async function capabilityCommand() {
3270
2271
  }
3271
2272
  }
3272
2273
 
3273
- // ---------- agent types ----------
3274
- function typeCmd() {
3275
- const sub = args[1];
3276
- const dir = dirFlag();
3277
- const file = join(dir, "oats-config.yaml");
3278
- if (sub === "list") {
3279
- const seen = new Map();
3280
- 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 });
3281
- if (!seen.size) { console.log("No agent types declared in the config chain."); return; }
3282
- for (const [name, { desc, level }] of seen) console.log(`${name} ${desc ? `— ${desc} ` : ""}[${shortPath(level)}]`);
3283
- return;
3284
- }
3285
- if (sub !== "add" || !args[2] || args[2].startsWith("--")) die("usage: oats type add <name> [--description <d>] [--dir <dir>] | oats type list [--dir <dir>]");
3286
- const name = args[2];
3287
- if (!/^[a-z][a-z0-9-]*$/.test(name)) die(`agent type "${name}" must be lowercase alphanumeric/hyphens`);
3288
- const description = flag("description");
3289
- let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
3290
- const cfg = existsSync(file) ? withConfigFile(file, () => parseYamlNested(text)) : {};
3291
- // Own-property: `constructor` is a legal agent-type name, and a plain lookup
3292
- // would report it as already declared in a config that never mentions it.
3293
- const declaredTypes = cfg["agent-types"];
3294
- if (declaredTypes && typeof declaredTypes === "object" && Object.hasOwn(declaredTypes, name)) die(`agent type "${name}" already declared in ${shortPath(file)}`);
3295
- // The NAME is already held to a strict grammar above; the DESCRIPTION was
3296
- // written verbatim onto its own line, so it could inject document the same
3297
- // way a `--settings` value could.
3298
- const block = [` ${name}:`, ...(description ? [` description: ${assertSafeConfigValue(description, "--description")}`] : [])];
3299
- const lines = text.replace(/\n*$/, "\n").split("\n");
3300
- // Drop the scaffold comment block once a real agent-types block exists.
3301
- const scaffold = lines.findIndex((l) => /^# ── Agent types/.test(l));
3302
- if (scaffold >= 0) {
3303
- let e = scaffold;
3304
- while (e < lines.length && (/^#/.test(lines[e]) || lines[e] === "")) { if (lines[e] === "" && !/^#/.test(lines[e + 1] || "x")) break; e++; }
3305
- lines.splice(scaffold, e - scaffold);
3306
- }
3307
- const start = lines.findIndex((l) => /^agent-types:\s*(#.*)?$/.test(l));
3308
- if (start >= 0) {
3309
- let end = start + 1;
3310
- while (end < lines.length && (/^\s/.test(lines[end]) || lines[end] === "")) { if (lines[end] === "" && !/^\s/.test(lines[end + 1] || "x")) break; end++; }
3311
- lines.splice(end, 0, ...block);
3312
- } else {
3313
- lines.splice(1, 0, "", "agent-types:", ...block);
3314
- }
3315
- writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"));
3316
- console.log(`Declared agent type "${name}" at ${levelOf(dir)} level (${shortPath(file)})`);
3317
- console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
3318
- }
3319
2274
 
3320
2275
  // ---------- update ----------
3321
2276
  function updateCmd() {
@@ -3370,7 +2325,7 @@ function versionCmd() {
3370
2325
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3371
2326
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3372
2327
  // never listed.
3373
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
2328
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "teams", "settings-declared", "capabilities-private", "layers-from"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
3374
2329
  return;
3375
2330
  }
3376
2331
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3508,7 +2463,7 @@ async function serverRouteCmd() {
3508
2463
  // The operations contract addresses an exact member context on the host,
3509
2464
  // so its explicit --dir travels; every other routed command takes its
3510
2465
  // scope from the registration.
3511
- const explicitScopeOk = ["inspect", "operation", "soul", "launch-config"].includes(cmd);
2466
+ const explicitScopeOk = ["inspect", "operation", "launch-config"].includes(cmd);
3512
2467
  if (!explicitScopeOk && (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir=")))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
3513
2468
  if (cmd === "launch-config") {
3514
2469
  const action = args[1];
@@ -3635,7 +2590,6 @@ async function serverRouteCmd() {
3635
2590
  // local --task-file is read here and travels as --task text, since the
3636
2591
  // remote cannot read this machine's files.
3637
2592
  const rest = [];
3638
- let routedInput;
3639
2593
  for (let i = 1; i < args.length; i++) {
3640
2594
  const a = args[i];
3641
2595
  if (a === "--server") { i++; continue; }
@@ -3665,23 +2619,10 @@ async function serverRouteCmd() {
3665
2619
  rest.push("--wake-message", readFileSync(f, "utf8"));
3666
2620
  continue;
3667
2621
  }
3668
- // Soul instructions travel as BYTES on the ssh stdin (the same transport
3669
- // as session upload), never as a path the host cannot read nor as a
3670
- // command-line argument.
3671
- if (a === "--instructions-file") {
3672
- const f = args[++i];
3673
- if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--instructions-file needs a path");
3674
- let bytes; try { bytes = readFileSync(f); } catch (e) { bail("E_BAD_ARGS", `instructions file not readable: ${f}: ${e.message}`); }
3675
- if (bytes.includes(0)) bail("E_BAD_ARGS", "--instructions-file must be text without NUL bytes");
3676
- if (bytes.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", `--instructions-file is ${bytes.length} bytes; the bound is ${INSPECT_TEXT_CAP}`);
3677
- routedInput = bytes;
3678
- rest.push("--instructions-stdin");
3679
- continue;
3680
- }
3681
2622
  rest.push(a);
3682
2623
  }
3683
2624
  let routed;
3684
- try { routed = routeCommand(id, cmd, rest, routedInput === undefined ? {} : { input: routedInput }); }
2625
+ try { routed = routeCommand(id, cmd, rest); }
3685
2626
  catch (e) { bail(e.code || "E_SSH", e.message); }
3686
2627
  const { envelope, stderr } = routed;
3687
2628
  if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
@@ -3706,7 +2647,8 @@ async function serverRouteCmd() {
3706
2647
  console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3707
2648
  for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
3708
2649
  console.log(`Work that was not committed has been preserved on ${target.sshHost}: ${(recovery.classes || []).join(", ")}`);
3709
- console.log(` ${recovery.path}`);
2650
+ console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
2651
+ for (const line of preservedOutputLines(recovery)) console.log(line);
3710
2652
  }
3711
2653
  if (r.rollbackIncomplete) { for (const f of r.rollbackIncomplete) console.error(` ${f}`); console.error(`Fix the cause there and re-run \`oats retire ${r.retired} --server ${id}\`.`); process.exit(1); }
3712
2654
  } else {
@@ -3735,70 +2677,44 @@ async function serverRouteCmd() {
3735
2677
  // blame` pointing at the commit that last changed each command.
3736
2678
  const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3737
2679
  /** Removed 0.24 verbs → their v2 replacement (workspace model v2, decision 5). Checked before capability dispatch. */
3738
- const REMOVED_VERBS = { install: "oats sync", restore: "oats sync", init: "oats-local.yaml + oats sync", use: "soul.yaml capabilities: { <cap>: { from } } + workspace defaults", trust: "oats sync (approval is asked once per package version)", list: "oats workspace status | oats capabilities", catalog: "oats package add <id> <version> (bare versions resolve through package-catalog.json)", remove: "oats package remove <id>", migrate: "a rebuild (no migration: docs/design/2026-09-23-workspace-module-contracts.md)", config: "oats-local.yaml (host settings) and oats-workspace.yaml (shared)", inject: "injection overrides are not part of the workspace model yet; edit the capability inject in its member repo" };
2680
+ const REMOVED_VERBS = { prepare: "`oats onboard` / `oats sync` to set up a workspace, and `oats spawn <soul> --preview` to see what a spawn would resolve (the captured/portable path was removed in 0.26)", create: "author souls/<name>/soul.yaml + AGENTS.md in a member repository, then `oats sync`", type: "the soul's own soul.yaml in its member repository (agent types were a classic config block)", soul: "the soul's soul.yaml / AGENTS.md in its member repository, then `oats sync` (per-spawn choices: spawn flags or a launch configuration)", install: "oats sync", restore: "oats sync", init: "oats-local.yaml + oats sync", use: "soul.yaml capabilities: { <cap>: { from } } + workspace defaults", trust: "declaring the package in packages: (package approval was removed; oats sync locks commit + integrity)", list: "oats workspace status | oats capabilities", catalog: "oats package add <id> <version> (bare versions resolve through package-catalog.json)", remove: "oats package remove <id>", migrate: "a rebuild (no migration: docs/design/2026-09-23-workspace-module-contracts.md)", config: "oats-local.yaml (host settings) and oats-workspace.yaml (shared)", inject: "injection overrides are not part of the workspace model yet; edit the capability inject in its member repo" };
3739
2681
  try {
3740
- // Inspect explicit selectors with the existing parser before new-work routing,
3741
- // including selectors before the command. Inherited captures are not prepare inputs.
3742
- let captured;
3743
- try { captured = capturedSelector(args, {}); }
3744
- catch (error) {
3745
- if (JSON_MODE) jsonFail(error.code || "E_BAD_ARGS", error.message);
3746
- die(error.message);
3747
- }
3748
- const sourceInspection = (cmd === "inspect" || captured?.args[0] === "inspect")
3749
- && args.some(arg => arg === "--request" || arg.startsWith("--request="));
3750
- if (sourceInspection) {
3751
- if (captured) {
3752
- if (JSON_MODE) jsonFail("E_BAD_ARGS", "source inspection is explicit new work and cannot use captured selectors");
3753
- die("source inspection is explicit new work and cannot use captured selectors");
3754
- }
3755
- if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
3756
- inspectOnboardingCmd(); process.exit(0);
3757
- }
3758
- if (cmd === "prepare" || captured?.args[0] === "prepare") {
3759
- if (captured) {
3760
- if (JSON_MODE) jsonFail("E_BAD_ARGS", "prepare is explicit new work and cannot use captured selectors");
3761
- die("prepare is explicit new work and cannot use captured selectors");
3762
- }
3763
- if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
3764
- prepareCmd(); process.exit(0);
3765
- }
3766
- if (cmd === "onboard" || captured?.args[0] === "onboard") {
3767
- if (captured) {
3768
- if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit workspace bootstrap and cannot use captured selectors");
3769
- die("onboard is explicit workspace bootstrap and cannot use captured selectors");
3770
- }
2682
+ // The captured/portable path was removed in 0.26 (lead decisions on (e), D2/D3):
2683
+ // its selectors and an inherited captured context are refused, never quietly
2684
+ // resolved against the current context instead. `version` answers regardless:
2685
+ // host protocol negotiation describes this executable.
2686
+ {
2687
+ const end = args.indexOf("--"), head = end < 0 ? args : args.slice(0, end);
2688
+ const selector = head.find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
2689
+ const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
2690
+ if (selector) refuse(`${selector.split("=")[0]}: a captured selector is refused (the captured/portable path was removed in 0.26); run the command in its workspace deployment or instance home instead`, { selector: selector.split("=")[0] });
2691
+ const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
2692
+ if (inherited.length && cmd !== "version") refuse(`this environment carries a captured context (${inherited.join(", ")}): the captured/portable path was removed in 0.26, and nothing is run against the current context in its place — retire the captured home and re-spawn it from the deployment`, { inherited });
2693
+ if (cmd === "inspect" && head.some((a) => a === "--request" || a.startsWith("--request="))) {
2694
+ const message = "oats inspect --request (portable onboarding inspection) is gone (the captured/portable path was removed in 0.26); use `oats onboard` / `oats sync` to set up a workspace and `oats spawn <soul> --preview` to see what a spawn would resolve";
2695
+ if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: "inspect --request", replacement: "oats onboard / oats sync; oats spawn --preview" });
2696
+ die(message);
2697
+ }
2698
+ }
2699
+ if (cmd === "onboard") {
3771
2700
  if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
3772
2701
  await onboardCmd();
3773
2702
  }
3774
2703
  else {
3775
- // Other commands retain their existing explicit/inherited selection rules.
3776
- try { captured ??= capturedSelector(args); }
3777
- catch (error) {
3778
- if (JSON_MODE) jsonFail(error.code || "E_BAD_ARGS", error.message);
3779
- die(error.message);
3780
- }
3781
- if (captured) {
3782
- args.splice(0, args.length, ...captured.args); cmd = args[0];
3783
- // Host protocol negotiation describes this executable, not a mutable
3784
- // configuration. An inherited capture must not break `oats version` probes.
3785
- if (captured.explicit || cmd !== "version") { capturedCommand(captured); process.exit(0); }
3786
- }
3787
2704
  // `--help`/`-h` anywhere after a kernel command prints that command's usage
3788
2705
  // and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
3789
2706
  // using a command, and `install --help` once ran the bare restore while
3790
2707
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
3791
2708
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
3792
2709
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
3793
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
2710
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"].includes(cmd)) await serverRouteCmd();
3794
2711
  else if (cmd === "server") serverCmd();
3795
- else if (cmd === "inspect") inspectCmd();
3796
- else if (cmd === "operation") operationCmd();
3797
- else if (cmd === "soul") await soulCmd();
2712
+ else if (cmd === "inspect") await inspectCmd();
2713
+ else if (cmd === "operation") await operationCmd();
3798
2714
  else if (cmd === "launch-config") await launchConfigCmd();
3799
2715
  else if (cmd === "doctor") {
3800
2716
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
3801
- args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
2717
+ await (args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir));
3802
2718
  }
3803
2719
  else if (cmd === "update") {
3804
2720
  // `oats update <package>` left with the installed tier (packages are pinned in
@@ -3807,8 +2723,7 @@ else if (cmd === "update") {
3807
2723
  if (t || flag("to") !== undefined) { cmdFail("E_BAD_ARGS", "oats update takes no package: pin package versions in oats-workspace.yaml (`oats package add <id> <version>`), then `oats sync`; bare `oats update [--check] [--yes]` updates the kernel"); process.exit(1); }
3808
2724
  updateCmd();
3809
2725
  }
3810
- else if (cmd === "type") typeCmd();
3811
- else if (cmd === "readiness") readinessCmd();
2726
+ else if (cmd === "readiness") await readinessCmd();
3812
2727
  else if (cmd === "instance") instanceCmd();
3813
2728
  else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
3814
2729
  else if (cmd === "sync") await syncCmd();
@@ -3824,7 +2739,6 @@ else if (cmd === "session") await sessionCmd();
3824
2739
  else if (cmd === "schedule") scheduleCmd();
3825
2740
  else if (cmd === "spawn") { try { await spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3826
2741
  else if (cmd === "retire") retireCmd();
3827
- else if (cmd === "create") createCmd();
3828
2742
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
3829
2743
  else if (cmd === "experimental") await experimentalCmd();
3830
2744
  // `!HELP_WORDS.has(cmd)`: usage NEVER depends on deployment state. `help` is a
@@ -3874,7 +2788,6 @@ Usage:
3874
2788
  oats version [--json] kernel version; --json emits the
3875
2789
  Desktop CLI API v1 probe payload
3876
2790
  oats status [--json] agents, souls, running instances
3877
- oats status --team [--json] whole-team roster across the team scope's repos
3878
2791
  oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3879
2792
  --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3880
2793
  [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
@@ -3898,24 +2811,19 @@ Usage:
3898
2811
  oats session start --server <id> start a stopped remote instance in its existing home
3899
2812
  --instance <name> | --home <abs> over its saved route; the server must advertise
3900
2813
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3901
- oats inspect|operation|soul --server <id> the same commands on a registered server over its
2814
+ oats inspect|operation --server <id> the same commands on a registered server over its
3902
2815
  ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
3903
2816
  is its own context; else the registered workspace);
3904
- soul set --instructions-file streams the bytes; the
3905
- server must advertise operations (oats 0.22.16 or later)
2817
+ the server must advertise operations (oats 0.22.16 or later)
3906
2818
  oats session upload --server <id> copy a local file into a remote instance's private
3907
2819
  --instance <name> | --home <abs> attachments over its saved route (bytes stream on
3908
2820
  --file <path> [--json] ssh stdin; sha256 verified); the server must
3909
2821
  advertise session-upload (oats 0.22.13 or later)
3910
2822
  oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
3911
- [--json] and agents/, then runs the oats sync path (lock v3;
3912
- exit 2 while approvals are pending) and prints the
2823
+ [--json] and agents/, then runs the oats sync path (lock v3)
2824
+ and prints the
3913
2825
  next steps (clone members you work IN, spawn
3914
2826
  oats-operator-expert); creates no soul, spawns nothing
3915
- oats create <name> [--local] [--no-oats-core] create an agent soul; --local = full
3916
- [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3917
- [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
3918
- [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
3919
2827
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3920
2828
  oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3921
2829
  enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
@@ -3940,19 +2848,21 @@ Usage:
3940
2848
  spawn hooks); --model replaces the recorded model
3941
2849
  for this and later starts; a live harness is refused
3942
2850
  oats spawn <agent> [--task <text>] spawn an instance (tmux/Herdr; --no-launch
3943
- [--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
3944
- [--parent <instance>] --def-file creates a local agent;
3945
- [--no-oats-core] omit the default only on NEW local souls;
2851
+ [--purpose <slug>] [--repo <r>] = scaffold only); the agent is a workspace
2852
+ [--parent <instance>] soul or a capability-defined agent
3946
2853
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3947
2854
  [--relative-to <instance>] new instance to an existing one; --parent X
3948
2855
  [--relative-root <agents-root>] disambiguates same-named team anchors
3949
2856
  [--work worktree|checkout|attached|workspace|directory] = sugar for --relative-to X --relation
3950
2857
  [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3951
- [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]
3952
- with team: declared, unknown local souls
2858
+ [--no-launch] [--json]
2859
+ with team: declared, unknown souls
3953
2860
  resolve across the team scope's repos
3954
2861
  directory: owned home/work, config context may
3955
2862
  be non-Git; rejects --work-dir and --branch
2863
+ [--name <slug>] the exact instance name (no <agent>- prefix;
2864
+ not with --purpose); must be a slug, not a
2865
+ soul name, and unused in the deployment
3956
2866
  oats retire <instance> [--force] retire an instance (window, hooks,
3957
2867
  [--self] [--delete-branch] worktree, home); --self = retire the
3958
2868
  [--keep-dir] [--json] CALLING instance: the window dies, then
@@ -3962,20 +2872,14 @@ Usage:
3962
2872
  [--json] installed capabilities with health, effective
3963
2873
  layer bindings and activation, declared
3964
2874
  operations with availability; --home answers the
3965
- running home's captured bindings and their drift
3966
- from the current config
3967
- oats operation run <layer>:<name> run an operation the effective provider of that
2875
+ running home's recorded modules and their drift
2876
+ from the deployment
2877
+ oats operation run <layer>:<name> run an operation the soul's core capability for that
3968
2878
  (--home <abs> | --soul <name> [--dir <d>]) layer declares (knowledge:harvest, knowledge:
3969
- [--arg k=v ...] [--json] inspect ...): resolved from the home's captured
3970
- bindings or the scope's config, trust checked, the provider's
2879
+ [--arg k=v ...] [--json] inspect ...): resolved from the home's recorded
2880
+ modules or the deployment's soul, trust checked, the provider's
3971
2881
  own command run in the home or scope, envelope
3972
2882
  relayed; a view answers {documents: [...]}
3973
- oats soul set <name> [--dir <scope>] change an editable soul's launch defaults and/or
3974
- [--agents-root <abs>] [--runtime r] instructions in place (soul.yaml lines replaced,
3975
- [--model m | --no-model] everything else kept; AGENTS.md replaced from
3976
- [--yolo | --no-yolo] [--backend b] --instructions-file); packaged souls are refused;
3977
- [--description d | --no-description] the receipt carries before/after and sha256s
3978
- [--instructions-file <path>] [--json]
3979
2883
  oats launch-config list [--dir <scope> named launch configurations effective at a scope,
3980
2884
  | --home <abs> | --soul <name>] a home's recorded context or a soul's own scope:
3981
2885
  [--agents-root <abs>] [--json] runtime, executable, args, env (values redacted,
@@ -3996,22 +2900,22 @@ Usage:
3996
2900
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
3997
2901
  optionally run the update; then run oats doctor
3998
2902
  oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
3999
- [--approve <id>@<version>]... oats-local.yaml over its Git remote, confirm every
2903
+ oats-local.yaml over its Git remote, confirm every
4000
2904
  member (reciprocal oats-membership.yaml), resolve
4001
- packages: to exact commits, ask executable approval
4002
- once per package version (TTY; otherwise list what
4003
- needs it and exit 2), write oats-lock.json (v3) and
4004
- report the diff. --approve approves exactly that
4005
- package at exactly that locked version without a
4006
- terminal (the digest is computed over the locked
4007
- tree; an id@version not in the lock is E_BAD_ARGS)
2905
+ packages: to exact commits + integrity, write
2906
+ oats-lock.json (v3) and report the diff. No approval
2907
+ step: declaring a package in packages: is the trust
2908
+ decision (--approve is E_BAD_ARGS)
4008
2909
  oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
4009
2910
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
4010
2911
  print the line to add (the file travels through Git)
4011
2912
  oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
4012
- cannot-read / backlink-elsewhere), packages, approval
4013
- oats capabilities [--dir <d>] [--json] every non-private capability of every confirmed
4014
- oats souls [--dir <d>] [--json] member + the locked packages, with origin
2913
+ cannot-read / backlink-elsewhere), locked packages
2914
+ oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
2915
+ private one is listed as repo-owned: usable only by
2916
+ its own repo's souls) + the locked packages
2917
+ oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
2918
+ (souls have no private mode), with origin
4015
2919
  (member <key> @ <commit> | package <id> v<ver>) and team
4016
2920
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
4017
2921
  read-only Git observation of the instance's work
@@ -4020,10 +2924,6 @@ Usage:
4020
2924
  oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json]
4021
2925
  bounded diff of one observed file; refuses when
4022
2926
  the tree moved since the observation
4023
- oats session recompose --home <abs> [--dry-run] [--json]
4024
- refresh a LIVE home's AGENTS.md from its current
4025
- canonical soul + context (same composer as spawn);
4026
- previous text retained; nothing restarted
4027
2927
  oats instance events <instance> [--limit <n>] [--since <iso>] [--json]
4028
2928
  typed lifecycle events (spawned, launched, stopped,
4029
2929
  restarted, retired, worktree-retained…) written by
@@ -4034,14 +2934,15 @@ Usage:
4034
2934
  oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
4035
2935
  quiesce (SIGTERM, bounded, never escalated),
4036
2936
  children first; home/work/launch retained
4037
- oats readiness [--soul <n> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--json]
4038
- quartet installed|trusted|configured|enrolled for a scope,
4039
- a soul, or an instance home (captured homes refuse:
4040
- their readiness is the retained resolution's)
4041
- installed | trusted | configured | enrolled, each
2937
+ oats readiness (--soul <n> | --home <abs>) [--policy] [--json]
2938
+ readinessApi 2 for a soul or an instance home:
2939
+ installed | configured | member | providers, each
4042
2940
  pass|fail|unknown|not-applicable with items and
4043
- remedies; signature status per artifact; enforced
4044
- child-spawn / worktree policy with origins
2941
+ remedies; providers relays each bound provider's
2942
+ own check ({status, problems, warnings});
2943
+ --policy: enforced child-spawn / worktree policy
2944
+ with origins (captured homes refuse: the
2945
+ captured/portable path was removed in 0.26)
4045
2946
  oats retire <instance> --plan [--json] what Remove would touch, with retention defaults
4046
2947
  oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--delete-branch]
4047
2948
  with a plan revision: refuses E_PLAN_STALE (fresh plan
@@ -4050,8 +2951,6 @@ Usage:
4050
2951
  <workspace>/.agents/worktrees/<repo>/<branch>)
4051
2952
  unless discarded; --delete-branch deletes the
4052
2953
  worktree's verified branch and implies discard
4053
- oats type add <name> [--description <d>] declare an agent type (family) in config;
4054
- oats type list souls join via create --type / soul.yaml
4055
2954
  oats root print this package's install root
4056
2955
  (adapters resolve the kernel from it)
4057
2956
 
@@ -4069,43 +2968,6 @@ The turn record (core — every conversation captured, searchable, replicated):
4069
2968
  design, repo checkout only; see
4070
2969
  packages/experimental/README.md
4071
2970
 
4072
- oats inspect --request <absolute-json-file> [--json]
4073
- [--emit-prepare-request <new-absolute-json-file>]
4074
- fresh source/workspace/member metadata; optional private
4075
- request export uses the existing fresh-request builder;
4076
- no provider execution or preparation authority
4077
- oats prepare --request <absolute-json-file> [--json]
4078
- complete public preparation input; no mixed flags,
4079
- inherited binding, implicit setup or launch authority
4080
- oats prepare --dir <abs> --source <git repo> --revision <ref> --export <path>
4081
- --alias <name> [--work <mode>] [--json] prepare retained commands and curriculum,
4082
- no launch; provider gaps report incomplete
4083
- oats prepare --dir <abs> --workspace <git repo> --alias <advertised alias>
4084
- [--workspace-revision <ref>] [--work <mode>] [--json]
4085
- same preparation through workspace imports
4086
- oats inspect --deployment <abs> --resolution <id> [--composition] [--json]
4087
- [--helper <exact-map-key>] inspect retained source/helper inputs, not today's configuration
4088
- oats trust <capability> --deployment <abs> --artifact-set <sha256-…> [--json]
4089
- approve one exact prepared artifact; use each
4090
- selections[].artifactSet for capability ids
4091
- in prepare's approvalRequired[] at that selection
4092
- oats trust <capability> --deployment <abs> --resolution <id> [--json]
4093
- explicitly approve that exact captured artifact
4094
- oats <namespace> <command> --deployment <abs> --resolution <id> -- [args…]
4095
- run the approved retained command; no ambient fallback
4096
- oats operation run <layer>:<name> --deployment <abs> --resolution <id>
4097
- [--home <abs>] [--arg k=v ...] [--retry-intent <saved-id>] [--json]
4098
- home actions admit distinct requests; explicit retry reuses intent
4099
- scope views stay read-only; scope mutation is not yet qualified
4100
- oats spawn <captured subject> --deployment <abs> --resolution <id>
4101
- --home <abs> --no-launch [--json] create a fresh directory scaffold and run captured hooks
4102
- oats session inspect --deployment <abs> --resolution <id> --home <abs> --native-record <UUID> [--helper <exact-key>] --json
4103
- read-only Pi completion observation; not admission or readiness
4104
- oats session start|restart --deployment <abs> --resolution <id> --home <abs>
4105
- [--helper <exact-source-map-key>] [--request <abs-json>] [--retry-intent <saved-id>] [--json]
4106
- dispatch the owned captured home via existing native custody;
4107
- version1 request: backend/task/stopGraceMs, no model override
4108
-
4109
2971
  oats <namespace> <command> [args…] run an operational command only when its
4110
2972
  capability is active (e.g. oats okf harvest)
4111
2973