@awebai/oats 0.25.9 → 0.27.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 (183) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +648 -1755
  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 +279 -93
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
  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 +8 -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-review/oats.json +3 -2
  20. package/docs/capabilities.md +229 -58
  21. package/docs/capability-manifest.schema.json +29 -9
  22. package/docs/configuration.md +17 -5
  23. package/docs/conventions.md +18 -28
  24. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  25. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  26. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  27. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  28. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  29. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  30. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  31. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  32. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
  33. package/docs/design/2026-09-24-phase-d-plan.md +57 -0
  34. package/docs/design/2026-09-25-teams-contract.md +226 -0
  35. package/docs/design/README.md +3 -3
  36. package/docs/design/launch-configurations.md +20 -16
  37. package/docs/design/operations-contract.md +27 -10
  38. package/docs/desktop-cli-api.md +604 -271
  39. package/docs/desktop-instance-start.md +3 -3
  40. package/docs/desktop.md +7 -13
  41. package/docs/execution-targets.md +16 -18
  42. package/docs/first-team.md +15 -18
  43. package/docs/implementation.md +31 -62
  44. package/docs/integrations.md +64 -33
  45. package/docs/knowledge-capability-authoring.md +1 -1
  46. package/docs/knowledge-reference/package-craft.md +10 -8
  47. package/docs/knowledge-theory.md +1 -1
  48. package/docs/knowledge.md +10 -11
  49. package/docs/layers.md +16 -17
  50. package/docs/oats-local.schema.json +30 -1
  51. package/docs/oats-membership.schema.json +5 -3
  52. package/docs/oats-package.schema.json +2 -2
  53. package/docs/oats-workspace.schema.json +1 -1
  54. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  55. package/docs/packages.md +76 -53
  56. package/docs/release-notes/v0.22.0.md +1 -1
  57. package/docs/release-notes/v0.23.1.md +1 -1
  58. package/docs/release-notes/v0.26.0.md +670 -0
  59. package/docs/release-notes/v0.27.0.md +100 -0
  60. package/docs/schedules.md +54 -132
  61. package/docs/servers.md +4 -4
  62. package/docs/soul.schema.json +11 -4
  63. package/docs/souls-and-instances.md +60 -47
  64. package/docs/workspaces.md +80 -58
  65. package/injects/instance-boundary.md +2 -2
  66. package/injects/work-attached.md +1 -1
  67. package/injects/work-workspace.md +2 -2
  68. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  69. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  70. package/lib/capability-contract.mjs +110 -0
  71. package/lib/config-data.mjs +2 -2
  72. package/lib/core.mjs +947 -5023
  73. package/lib/deprecation.mjs +24 -0
  74. package/lib/digest.mjs +12 -0
  75. package/lib/instance-inspect.mjs +397 -0
  76. package/lib/instance-lifecycle.mjs +3 -4
  77. package/lib/instance-resolution.mjs +212 -26
  78. package/lib/instruction-composition.mjs +0 -20
  79. package/lib/materialize.mjs +6 -4
  80. package/lib/operator-dispatch.mjs +33 -13
  81. package/lib/packages.mjs +25 -190
  82. package/lib/process-group.mjs +1 -1
  83. package/lib/provider-binding.mjs +4 -2
  84. package/lib/provider-reasons.mjs +3 -68
  85. package/lib/remote.mjs +1 -1
  86. package/lib/resolve.mjs +204 -68
  87. package/lib/schedule.mjs +136 -292
  88. package/lib/servers.mjs +70 -38
  89. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  90. package/lib/tree-copy.mjs +44 -0
  91. package/lib/workspace.mjs +132 -20
  92. package/package-catalog.json +6 -6
  93. package/package.json +1 -1
  94. package/packages/record/lib/session-roots.mjs +8 -6
  95. package/skills/integration-authoring/SKILL.md +48 -40
  96. package/skills/oats-getting-started/SKILL.md +105 -110
  97. package/skills/oats-support/SKILL.md +2 -2
  98. package/skills/soul-craft/SKILL.md +13 -6
  99. package/bin/oats-pi-sdk-host.mjs +0 -17
  100. package/docs/2026-09-03-architecture-proposal.md +0 -642
  101. package/docs/artifact-approvals.schema.json +0 -7
  102. package/docs/captured-invocation-context.schema.json +0 -7
  103. package/docs/captured-resolution.schema.json +0 -7
  104. package/docs/design/package-engine-contract.md +0 -813
  105. package/docs/design/package-runtime-api.md +0 -588
  106. package/docs/desktop-succession.md +0 -57
  107. package/docs/execution-capsule.schema.json +0 -108
  108. package/docs/first-team-demo.md +0 -92
  109. package/docs/knowledge-migration.md +0 -147
  110. package/docs/migration-from-oas.md +0 -103
  111. package/docs/oats-config.schema.json +0 -172
  112. package/docs/oats-lock-v3.schema.json +0 -7
  113. package/docs/oats-lock.schema.json +0 -175
  114. package/docs/operating-team-migration.md +0 -470
  115. package/docs/portable.schema.json +0 -2512
  116. package/docs/provider-check-input.schema.json +0 -7
  117. package/docs/rebuild-to-v2.md +0 -511
  118. package/docs/workspace-adoption.md +0 -74
  119. package/injects/framework-workspace.md +0 -7
  120. package/injects/local-soul.md +0 -19
  121. package/injects/oats-portable.md +0 -20
  122. package/injects/oats.md +0 -11
  123. package/injects/portable-instance-boundary.md +0 -39
  124. package/injects/portable-work-directory.md +0 -29
  125. package/lib/artifact-approvals.mjs +0 -120
  126. package/lib/artifact-tree.mjs +0 -141
  127. package/lib/capability-artifacts.mjs +0 -179
  128. package/lib/capability-execution.mjs +0 -15
  129. package/lib/capability-inputs.mjs +0 -39
  130. package/lib/capability-provenance.mjs +0 -231
  131. package/lib/captured-action-shape.mjs +0 -21
  132. package/lib/captured-admission-shape.mjs +0 -20
  133. package/lib/captured-binding-file.mjs +0 -36
  134. package/lib/captured-dispatch.mjs +0 -66
  135. package/lib/captured-instance-index.mjs +0 -277
  136. package/lib/captured-invocation-context.mjs +0 -130
  137. package/lib/captured-launch-request.mjs +0 -66
  138. package/lib/captured-operation-process.mjs +0 -15
  139. package/lib/captured-pi-custody.mjs +0 -29
  140. package/lib/captured-pi-host.mjs +0 -167
  141. package/lib/captured-pi-outcome.mjs +0 -172
  142. package/lib/captured-resolutions.mjs +0 -275
  143. package/lib/captured-scaffold.mjs +0 -87
  144. package/lib/captured-selector.mjs +0 -28
  145. package/lib/captured-session-backend.mjs +0 -52
  146. package/lib/captured-source-receipt-file.mjs +0 -72
  147. package/lib/helper-injection-policy.mjs +0 -104
  148. package/lib/legacy-lock-codec.mjs +0 -106
  149. package/lib/manifest-settings.mjs +0 -84
  150. package/lib/package-closure.mjs +0 -48
  151. package/lib/package-materialization.mjs +0 -83
  152. package/lib/pi-sdk-host.mjs +0 -229
  153. package/lib/portable-artifacts.mjs +0 -115
  154. package/lib/portable-choices.mjs +0 -82
  155. package/lib/portable-composition.mjs +0 -136
  156. package/lib/portable-digest.mjs +0 -105
  157. package/lib/portable-identity.mjs +0 -40
  158. package/lib/portable-lock.mjs +0 -117
  159. package/lib/portable-onboarding-request.mjs +0 -49
  160. package/lib/portable-onboarding.mjs +0 -256
  161. package/lib/portable-package-preparation.mjs +0 -188
  162. package/lib/portable-policy.mjs +0 -44
  163. package/lib/portable-soul.mjs +0 -42
  164. package/lib/portable-state.mjs +0 -80
  165. package/lib/prepare-composition.mjs +0 -170
  166. package/lib/prepared-bindings.mjs +0 -92
  167. package/lib/prepared-resources.mjs +0 -127
  168. package/lib/provider-binding-broker.mjs +0 -65
  169. package/lib/provider-binding-wire.mjs +0 -116
  170. package/lib/readiness.mjs +0 -225
  171. package/lib/repository-observation.mjs +0 -226
  172. package/lib/resolution-shape.mjs +0 -393
  173. package/lib/schedule-capsule.mjs +0 -206
  174. package/lib/soul-constraints.mjs +0 -40
  175. package/lib/source-projection.mjs +0 -84
  176. package/lib/source-spec.mjs +0 -189
  177. package/lib/workspace-definition.mjs +0 -126
  178. package/lib/workspace-discovery.mjs +0 -146
  179. package/skills/oats/SKILL.md +0 -162
  180. package/skills/oats-config/SKILL.md +0 -164
  181. package/skills/oats-packages/SKILL.md +0 -184
  182. package/skills/oats-portable/SKILL.md +0 -115
  183. 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,24 @@
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";
25
+ import { runtimeNameWarning, noteRuntimeName } from "../lib/deprecation.mjs";
26
26
  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,
27
+ LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
28
+ capabilityManifests, capabilityTrust, capabilityExecutablePath,
29
+ officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
30
+ 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_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
36
31
  } from "../lib/core.mjs";
37
32
  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";
33
+ writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages,
34
+ classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
35
+ import { loadLocal, validateWorkspace, validateLocal } from "../lib/workspace.mjs";
36
+ import { parseConfigData } from "../lib/config-data.mjs";
42
37
  import * as remoteModule from "../lib/remote.mjs";
43
- import { createInterface } from "node:readline/promises";
44
38
  import YAML from "yaml";
45
39
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
46
40
  import { spawnSync as spawnSyncProc } from "node:child_process";
@@ -48,25 +42,16 @@ import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describe
48
42
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
49
43
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
50
44
 
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
45
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
61
46
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
62
47
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
63
- import { readinessOf, policyOf } from "../lib/readiness.mjs";
48
+ import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
64
49
  import { readEvents } from "../lib/instance-events.mjs";
65
50
 
66
51
  const args = process.argv.slice(2);
67
52
  let cmd = args[0];
68
53
  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"]);
54
+ 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
55
  const flag = (name) => {
71
56
  const i = args.indexOf(`--${name}`);
72
57
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -81,7 +66,17 @@ function valueFlag(name) {
81
66
  return value;
82
67
  }
83
68
  const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
84
- const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
69
+ /** A command's harness: --harness, or --runtime, its pre-0.27 name (the released okf worker and
70
+ * a 0.26-era Desktop pass it) — read either, with the deprecation warning. Both, disagreeing,
71
+ * are refused. `get` reads one flag (the command's own reader where it has one). */
72
+ function harnessFlag(get = flag) {
73
+ const harness = get("harness"), runtime = get("runtime");
74
+ if (runtime === undefined) return harness;
75
+ if (harness !== undefined && harness !== runtime) cmdFail("E_BAD_ARGS", `--harness ${harness} and --runtime ${runtime} disagree; --runtime is the pre-0.27 name of --harness — give one`);
76
+ noteRuntimeName("the --runtime flag (use --harness)");
77
+ return runtime;
78
+ }
79
+ const cmdFail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
85
80
  /** Resolve the --dir flag with central validation: a value-taking flag given
86
81
  * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
87
82
  * uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
@@ -102,384 +97,33 @@ const JSON_MODE = args.includes("--json");
102
97
  // Canonical absolute path of this CLI executable — the versioned OATS_CLI_BIN
103
98
  // env contract for dispatched package commands (never resolved via PATH).
104
99
  const CLI_BIN = realpathSync(fileURLToPath(import.meta.url));
105
- 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
- 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";
100
+ // The one deprecated-name warning (lib/deprecation.mjs) rides the envelope, only when there is one.
101
+ const envelopeWarnings = () => { const w = runtimeNameWarning(); if (w) warningDelivered = true; return w ? { warnings: [w] } : {}; };
102
+ let warningDelivered = false;
103
+ /** A forwarded envelope keeps the host's warnings; this command's own deprecated-name
104
+ * note (a `--runtime` given here) joins the host's into the one warning. */
105
+ const withLocalWarnings = (envelope) => {
106
+ const mine = runtimeNameWarning();
107
+ if (!mine || !envelope || typeof envelope !== "object") return envelope;
108
+ warningDelivered = true;
109
+ const theirs = Array.isArray(envelope.warnings) ? envelope.warnings : [];
110
+ const same = theirs.find((w) => w?.code === mine.code);
111
+ if (!same) return { ...envelope, warnings: [...theirs, mine] };
112
+ const sources = [...new Set([...(Array.isArray(same.sources) ? same.sources : []), ...mine.sources])];
113
+ return { ...envelope, warnings: theirs.map((w) => (w === same ? { ...mine, sources, message: mine.message.replace(/\(.*\)/, `(${sources.join("; ")})`) } : w)) };
114
+ };
115
+ const jsonFail = (code, message, details) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message), ...(details !== undefined ? { details } : {}) }, ...envelopeWarnings() })); process.exit(1); };
116
+ const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result, ...envelopeWarnings() })); };
117
+ // Text mode (or a JSON answer printed before the read): the warning goes to stderr, never stdout.
118
+ process.on("exit", () => { const w = runtimeNameWarning(); if (w && !warningDelivered) process.stderr.write(`oats: warning: ${w.message}\n`); });
119
+ 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`;
120
+ /** A retire recovery's copied outputs (untracked/ignored or directory work), named with their size. */
121
+ function preservedOutputLines(recovery) {
122
+ const outputs = recovery?.outputs;
123
+ if (!outputs?.paths?.length) return [];
124
+ const shown = outputs.paths.slice(0, 8).map((p) => `${p.path} (${formatBytes(p.bytes)})`);
125
+ const more = outputs.paths.length > 8 ? `, and ${outputs.paths.length - 8} more` : "";
126
+ return [` copied outputs: ${shown.join(", ")}${more} — ${formatBytes(outputs.bytes)} in total`];
483
127
  }
484
128
 
485
129
  function shortPath(p) {
@@ -493,56 +137,63 @@ function shellQuote(s) {
493
137
  return /^[A-Za-z0-9._/~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
494
138
  }
495
139
 
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
140
 
508
141
  // ---------- doctor ----------
509
142
  /** Doctor must diagnose, not crash: a stale activation of a retired
510
143
  * capability fails config resolution — surface the cleanup instruction
511
144
  * 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
145
  function operationalKnowledgeNote(composition, soulName) {
532
146
  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) {
147
+ ? `soul ${soulName} has no oats.core capability (the workspace default); it gets no OATS operating instructions` : null;
148
+ }
149
+ /** `doctor --soul`: the instructions an instance of that soul would carry. The soul
150
+ * is resolved over the workspace remotes exactly as a spawn preview resolves it,
151
+ * the kernel half composed, and the modules materialized into a scratch home
152
+ * OUTSIDE the deployment (removed after), so module injects are part of the text.
153
+ * Nothing in the deployment is written. Block files of module injects are named
154
+ * home-relative (`.oats/modules/<cap>/<inject>`), where an instance carries them. */
155
+ async function doctorComposition(ctx, soulName, ws, bail) {
536
156
  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);
157
+ const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
158
+ const deployment = dirname(ws.local.path);
159
+ const root = join(deployment, "agents");
160
+ const remoteOptions = remoteOptionsFromEnv();
161
+ const cleanups = [];
162
+ // A bail exits the process without unwinding: the temporary copies are removed at exit too.
163
+ process.once("exit", () => { for (const c of cleanups) { try { c(); } catch { /* best effort */ } } });
164
+ try {
165
+ const discovery = await discoverOrStandalone(loadLocal(deployment).local, { remoteOptions });
166
+ const prepared = await prepareInstance(deployment, soulName, { remoteOptions, discovery });
167
+ const pv = await previewWorkspaceSoul(prepared, root);
168
+ cleanups.push(pv.cleanup);
169
+ const agent = findAgentAt(root, prepared.soulEntry.name, pv.soulDir);
170
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${soulName}" was fetched but is not readable as a soul`);
171
+ const composition = composeInstanceAgentsMd(pv.soulDir, deployment, agent.name, agent.work || "checkout", agent.kind, prepared);
172
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "oats-doctor-home-")));
173
+ cleanups.push(() => rmSync(scratch, { recursive: true, force: true }));
174
+ const outcome = await materializePrepared({ ...prepared, soulAgentsMd: composition.text, soulDir: pv.soulDir }, scratch);
175
+ const text = readFileSync(join(scratch, "AGENTS.md"), "utf8");
176
+ const known = new Set(composition.blocks.map((b) => b.source));
177
+ const outcomeBlocks = Array.isArray(outcome?.blocks) ? outcome.blocks : [];
178
+ for (const m of text.matchAll(/^<!-- oats:(capability:[^\s]+) src=(.+?) -->$/gm)) {
179
+ const [, source, file] = m;
180
+ if (known.has(source)) continue;
181
+ known.add(source);
182
+ const content = outcomeBlocks.find((b) => b.source === source && b.file === file)?.content ?? (existsSync(file) ? readFileSync(file, "utf8").trim() : "");
183
+ const rel = file.startsWith(scratch + sep) ? file.slice(scratch.length + 1) : file;
184
+ composition.blocks.push({ source, file: rel, content, materialized: true });
185
+ }
186
+ return { ...composition, text };
187
+ } catch (e) {
188
+ if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
189
+ throw e;
190
+ } finally { for (const c of cleanups) { try { c(); } catch { /* best effort: temporary copies only */ } } }
541
191
  }
542
192
 
543
193
  /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
544
194
  * 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`. */
195
+ * goes to the network for this view (only `--soul`, which resolves the soul like a
196
+ * spawn preview); membership and discovery are `oats sync` / `oats workspace status`. */
546
197
  function doctorLockData(ctx) {
547
198
  const out = { local: null, localError: null, lockFile: null, packages: [], lockError: null };
548
199
  let lockDir = ctx;
@@ -551,7 +202,9 @@ function doctorLockData(ctx) {
551
202
  out.local = { path: found.path, workspace: found.local.workspace };
552
203
  lockDir = dirname(found.path);
553
204
  } catch (e) {
554
- if (e?.code === "E_WORKSPACE_SCHEMA") out.localError = { code: e.code, message: e.message };
205
+ // An unreadable oats-local.yaml, or a 0.25 oats-config.yaml inside the deployment
206
+ // (E_CONFIG_BROKEN reason legacy-config): doctor answers it as its typed error.
207
+ if (e?.code === "E_WORKSPACE_SCHEMA" || e?.code === "E_CONFIG_BROKEN") out.localError = { code: e.code, message: e.message, details: e.details };
555
208
  else if (e?.code !== "E_LOCAL_MISSING") throw e;
556
209
  }
557
210
  const file = join(lockDir, LOCK_FILE);
@@ -559,7 +212,7 @@ function doctorLockData(ctx) {
559
212
  out.lockFile = file;
560
213
  try {
561
214
  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 }));
215
+ 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
216
  } catch (e) {
564
217
  if (e?.code !== "E_LOCK_SCHEMA") throw e;
565
218
  out.lockError = { code: e.code, message: e.message, file: e.details?.file ?? file };
@@ -567,45 +220,6 @@ function doctorLockData(ctx) {
567
220
  return out;
568
221
  }
569
222
 
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
223
  // ---------- inspect: one authoritative answer for GUIs ----------
610
224
  /** Souls, capabilities (installed state and health, separately from
611
225
  * activation), effective layer bindings and declared operations for a
@@ -614,35 +228,19 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
614
228
  * from its roster poll). Nothing here is provider-specific: what a
615
229
  * knowledge provider offers is what its manifest declares. */
616
230
  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
- }
636
- const SOUL_FIELDS = ["runtime", "model", "yolo", "backend", "description", "launch-config"];
231
+ /** The agents root a home belongs to, from its path alone:
232
+ * <root>/<agent>/instances/<instance>. */
233
+ function agentsRootOfHome(home) { return dirname(dirname(dirname(home))); }
234
+ const SOUL_FIELDS = ["harness", "model", "yolo", "backend", "description", "launch-config"];
637
235
  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
236
+ /** Every soul of a scope: the persistent souls of every agents root in
639
237
  * scope, plus packaged souls (read-only). One enumeration for inspect and
640
238
  * operation run, so both address souls the same way. */
641
- function scopeSouls(ctx, r, { extraRoots = [] } = {}) {
239
+ function scopeSouls(ctx, { extraRoots = [] } = {}) {
642
240
  // A home's own agents root is always in scope for that home: its recorded
643
241
  // work repository may be another repository entirely (repo overrides), and
644
242
  // 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))))];
243
+ const roots = [...new Set([findRoot(ctx), ...extraRoots].filter(Boolean).map((p) => realOrResolved(resolve(p))))];
646
244
  const souls = [];
647
245
  for (const root of roots) {
648
246
  for (const a of listInstances(root)) {
@@ -652,17 +250,7 @@ function scopeSouls(ctx, r, { extraRoots = [] } = {}) {
652
250
  souls.push(e);
653
251
  }
654
252
  }
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 };
253
+ return { roots, souls, diagnostics: [] };
666
254
  }
667
255
  /** The one soul a name (and optional agents root) addresses; throws with a
668
256
  * code when none or several match. */
@@ -733,7 +321,7 @@ function soulEntry(soul, root, { capability } = {}) {
733
321
  declarationProblems: declared.problems,
734
322
  name: soul.name, kind: packaged ? "capability" : (soul.kind || "persistent"), capability: capability || null,
735
323
  type: soul.type ?? null, description: soul.description ?? null, repo: soul.repo ?? null, work: soul.work || "checkout",
736
- runtime: soul.runtime || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, launchConfig: soul["launch-config"] ?? null, backend: soul.backend ?? null,
324
+ harness: soul.harness || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, launchConfig: soul["launch-config"] ?? null, backend: soul.backend ?? null,
737
325
  agentsRoot: root, dir: packaged ? soulDir : dir, soulFile: join(soulDir, "soul.yaml"), instructionsFile: join(soulDir, "AGENTS.md"),
738
326
  editable: packaged
739
327
  ? { fields: [], instructions: false, reason: `packaged soul from capability ${capability}: edit the package and update it; scoped bindings still apply through oats use` }
@@ -745,215 +333,71 @@ function soulEntry(soul, root, { capability } = {}) {
745
333
  * invoking process's ambient agents-root override must not redirect them
746
334
  * to its own deployment. */
747
335
  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)));
336
+ async function inspectCmd() {
337
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
338
+ const t = await workspaceTarget(bail, { command: "inspect" });
339
+ if (t) {
340
+ if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
341
+ const doc = inspectDocument(t, { kernel: OATS_VERSION });
342
+ if (JSON_MODE) { jsonOk(doc); return; }
343
+ printWorkspaceInspect(doc); return;
344
+ }
345
+ }
346
+ /** The workspace-model target of inspect / readiness / operation run (lead
347
+ * decision 4): an instance home with materialized modules (`--home`), or a soul
348
+ * of a workspace deployment (`--soul`, resolved as its spawn would be). Anything
349
+ * else is a typed refusal: there is no classic scope answer. */
350
+ async function workspaceTarget(bail, { command, liveTeams = true }) {
754
351
  dropAmbientRoot();
755
- const homeFlag = flag("home");
756
- const home = homeFlag === true ? bail("E_BAD_ARGS", "--home needs an absolute instance home") : homeFlag;
352
+ const homeFlag = flag("home"), soulFlag = flag("soul"), rootFlag = flag("agents-root");
353
+ if (homeFlag === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
354
+ if (soulFlag === true) return bail("E_BAD_ARGS", "--soul needs a soul name");
355
+ if (rootFlag === true) return bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
356
+ const remoteOptions = remoteOptionsFromEnv();
357
+ if (homeFlag) {
358
+ if (!isAbsolute(homeFlag)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
359
+ let meta = null;
360
+ 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})`); }
361
+ if (isCapturedHome(meta)) { const e = capturedHomeRefusal(homeFlag, "nothing was read"); return bail(e.code, e.message, e.details); }
362
+ 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`);
363
+ if (soulFlag && soulFlag !== meta.agent) return bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${homeFlag} (${meta.agent})`);
364
+ const deployment = dirname(dirname(dirname(dirname(realOrResolved(homeFlag)))));
365
+ // A v2 home lives at <deployment>/agents/<soul>/instances/<name>: its deployment is
366
+ // derived, so it must hold oats-local.yaml EXACTLY there (never found by walking up).
367
+ 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") });
368
+ 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`); }
369
+ if (rootFlag && realOrResolved(rootFlag) !== realOrResolved(join(deployment, "agents"))) return bail("E_HOME_MISMATCH", `--agents-root ${rootFlag} is not the agents root of ${homeFlag}`);
370
+ return homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams });
371
+ }
372
+ 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`); }
373
+ catch (e) { return bail(e?.code || "E_WORKSPACE_SCHEMA", e?.message || String(e), e?.details); }
374
+ 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)" : ""}`);
375
+ const deployment = dirname(loadLocal(dirFlag()).path);
376
+ 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")})`);
377
+ try { return await soulTarget(dirFlag(), String(soulFlag), { remoteOptions }); }
378
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
379
+ }
380
+ function printWorkspaceInspect(doc) {
381
+ const s = doc.subject;
382
+ console.log(`oats inspect — ${s.kind === "instance" ? `instance ${s.instance} (soul ${s.soul}) ${shortPath(s.home)}` : `soul ${s.soul} from ${s.repoKey}`}`);
383
+ if (doc.identity) console.log(` identity: ${servedIdentityLine(doc.identity)}`);
384
+ for (const l of LAYERS) console.log(` ${l} capability: ${doc.layers[l].id || "none"}`);
385
+ 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(", ")}` : ""}`);
386
+ for (const p of doc.problems) console.log(` ! ${p.code}: ${p.message}`);
387
+ }
388
+
389
+ /** `{ teams, teamsSource }` for a session start of a workspace home: its eligible teams read
390
+ * live (two repository reads), which the launch hook re-checks joined memberships against
391
+ * (teams contract decision 6) — or the spawn record, marked `recorded`, when the read cannot
392
+ * answer. Anything that is not a readable workspace home gets nothing here — the start
393
+ * itself refuses it. */
394
+ async function homeLiveTeams(home) {
757
395
  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}`);
396
+ try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return {}; }
397
+ if (!isWorkspaceHome(meta)) return {};
398
+ const { liveTeams } = await import("../lib/instance-resolution.mjs");
399
+ const { teams, source } = await liveTeams(home, meta, { remoteOptions: remoteOptionsFromEnv() });
400
+ return Array.isArray(teams) ? { teams, teamsSource: source } : {};
957
401
  }
958
402
 
959
403
  // ---------- operation run: generic invoke through the capability engine ----------
@@ -969,11 +413,11 @@ const OPERATION_ADDRESS_RE = /^(knowledge|messaging|tasks):([a-z][a-z0-9-]*)$/;
969
413
  const reportsRetainedEffectsText = (message) => /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || ""));
970
414
  // Comfortably below the scheduler's 5-minute command bound and any GUI
971
415
  // 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 }) {
416
+ const OPERATION_TIMEOUT_MS = 4 * 60 * 1000;
417
+ function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home, meta, cleanupError, intent, settlement, api }) {
974
418
  const stderr = String(r.stderr || "").trim();
975
419
  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 };
420
+ 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
421
  // Unconfirmed outcomes (a timeout, no valid receipt, a receipt contradicted
978
422
  // by the exit status) carry what WAS observed in error.details, so a
979
423
  // scheduler can keep the slot as unknown and reconcile by any name the
@@ -1018,58 +462,8 @@ function finishOperation({ r, bail, address, provider, op, argFlags, cwd, home,
1018
462
  else console.log(JSON.stringify(result, null, 2));
1019
463
  if (stderr) console.error(stderr);
1020
464
  }
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.
465
+ /** --arg k=v pairs of `oats operation run`. */
466
+ function operationArgs(bail) {
1073
467
  const given = Object.create(null);
1074
468
  for (let i = 3; i < args.length; i++) {
1075
469
  if (args[i] !== "--arg") continue;
@@ -1079,210 +473,92 @@ function operationCmd() {
1079
473
  given[kv.slice(0, eq)] = kv.slice(eq + 1);
1080
474
  i++;
1081
475
  }
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}`);
476
+ return given;
477
+ }
478
+ /** `oats operation run` on the workspace model (operationsApi 2): the provider is
479
+ * the module filling <layer> — the home's own copy, or the soul's resolved module
480
+ * fetched into the deployment's module store — with its merged payload as
481
+ * OATS_SETTINGS and the team facts hooks get. */
482
+ async function workspaceOperation(t, { bail, address, layer, opName }) {
483
+ if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
484
+ const name = t.slots[layer];
485
+ const mod = name ? t.modules.find((x) => x.name === name) : null;
486
+ if (!mod?.manifest) return bail("E_OPERATION_UNAVAILABLE", `no ${layer} provider is resolved for ${t.home ? t.home : `soul ${t.soul.name}`}`);
487
+ const provider = { ...mod.manifest, capability: mod.name };
1102
488
  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>`);
489
+ if (!op) return bail("E_OPERATION_UNKNOWN", `${mod.name} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
490
+ const missingReq = manifestMissingRequires(provider);
491
+ 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`);
492
+ if (op.context === "home" && !t.home) return bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
493
+ const given = operationArgs(bail);
1109
494
  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"}`);
495
+ 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"})`);
496
+ 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
497
  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`);
498
+ const spec = provider.commands?.[op.command];
499
+ if (typeof spec !== "string" || !spec.trim()) return bail("E_CAPABILITY_BROKEN", `${mod.name}: command ${op.command} is not a non-empty string`);
500
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
501
+ const { layerProvider } = await import("../lib/instance-inspect.mjs");
502
+ let lp;
503
+ try { lp = await layerProvider(t, layer, { catalog, remoteOptions: remoteOptionsFromEnv() }); } catch (e) { return bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
1115
504
  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 });
505
+ const abs = lp?.executable(script);
506
+ if (!abs) return bail("E_CAPABILITY_BROKEN", `${mod.name} ${op.command}: script not found (${script})`);
507
+ const settings = lp.settings;
508
+ const cwd = op.context === "home" ? t.home : t.deployment;
509
+ const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
510
+ 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 });
511
+ else for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
1136
512
  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 });
513
+ finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
1138
514
  }
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));
515
+ async function operationCmd() {
516
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1146
517
  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.");
518
+ 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]");
519
+ const address = args[2];
520
+ const m0 = typeof address === "string" ? OPERATION_ADDRESS_RE.exec(address) : null;
521
+ if (!m0) bail("E_BAD_ARGS", `operation address must be <layer>:<name> with layer one of ${LAYERS.join(", ")} (got ${JSON.stringify(address)})`);
522
+ const [, layer, opName] = m0;
523
+ // Only the messaging provider's operations act on the teams: others get the record, no remote read.
524
+ const target = await workspaceTarget(bail, { command: "operation run", liveTeams: layer === "messaging" });
525
+ return workspaceOperation(target, { bail, address, layer, opName });
1225
526
  }
1226
527
 
1227
- function doctorJson(dir) {
1228
- const ctx = resolve(dir || process.cwd());
1229
- const soulName = flag("soul");
528
+
529
+ /** Doctor answers on a workspace deployment only (lead decision c3-6): the
530
+ * deployment found walking up from the given directory (positional or --dir),
531
+ * else E_LOCAL_MISSING; an unreadable oats-local.yaml is its own error. */
532
+ function doctorDeployment(dir) {
533
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
534
+ const ctx = resolve(dir || dirFlag());
1230
535
  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));
536
+ if (ws.localError) bail(ws.localError.code, ws.localError.message, ws.localError.details);
537
+ 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 });
538
+ return { ctx, ws };
539
+ }
540
+ async function doctorJson(dir) {
541
+ const { ctx, ws } = doctorDeployment(dir);
542
+ console.log(JSON.stringify(await doctorWorkspaceJson(ctx, flag("soul"), ws), null, 2));
1274
543
  }
1275
544
 
1276
545
  /** The v2 doctor payload: the deployment declaration + lock (offline) and, with
1277
546
  * --soul, the composed instructions. No v1 keys (chain/layers/acquired/injects…). */
1278
- function doctorWorkspaceJson(ctx, soulName, ws) {
1279
- const composition = doctorComposition(ctx, soulName);
547
+ /** The problems doctor and status share for a v2 deployment: what OATS 0.25 left
548
+ * under local-agents/ (named, never read), and the captured homes under the agents root. */
549
+ function legacyLayoutProblems(root) {
550
+ return [legacyLocalAgents(root), legacyCapturedHomes(root)].filter(Boolean);
551
+ }
552
+ async function doctorWorkspaceJson(ctx, soulName, ws) {
553
+ const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
554
+ const problems = legacyLayoutProblems(join(dirname(ws.local.path), "agents"));
1280
555
  return {
1281
556
  schemaVersion: 1, workspaceApi: 2, context: ctx,
1282
557
  workspace: { file: ws.local.path, ref: ws.local.workspace },
1283
558
  workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
1284
559
  information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1285
560
  composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
561
+ ...(problems.length ? { problems } : {}),
1286
562
  };
1287
563
  }
1288
564
  /** Kernel/bridge version skew (published in lockstep from one tag). */
@@ -1295,142 +571,26 @@ function doctorVersionSkew() {
1295
571
  /** The "Workspace (v2, offline view)" + "Locked packages" sections, shared by both doctor shapes. */
1296
572
  function printDoctorWorkspace(ws) {
1297
573
  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)");
574
+ console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
1301
575
  console.log("\nLocked packages (oats-lock.json v3):");
1302
576
  if (ws.lockError) {
1303
577
  console.log(` ERROR: ${ws.lockError.message} [${ws.lockError.code}]`);
1304
578
  if (ws.lockError.file) console.log(` the lock is never auto-repaired; delete ${shortPath(ws.lockError.file)} and run \`oats sync\``);
1305
579
  } else if (!ws.packages.length) console.log(ws.lockFile ? " (none)" : " (no lock yet — run `oats sync`)");
1306
580
  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)"}`);
581
+ console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.integrity}`);
1308
582
  if (p.capabilities.length) console.log(` capabilities: ${p.capabilities.join(", ")}`);
1309
583
  }
1310
584
  console.log(" membership, discovery and drift need the remotes: `oats workspace status`, `oats sync`.");
1311
585
  }
1312
- function doctor(dir) {
1313
- const ctx = resolve(dir || process.cwd());
586
+ async function doctor(dir) {
587
+ const { ctx, ws } = doctorDeployment(dir);
1314
588
  const soulName = flag("soul");
1315
- const ws = doctorLockData(ctx);
1316
589
  console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1317
590
  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`.
591
+ const composition = await doctorComposition(ctx, soulName, ws, (code, msg) => die(`${msg} [${code}]`));
1432
592
  printDoctorWorkspace(ws);
1433
-
593
+ for (const p of legacyLayoutProblems(join(dirname(ws.local.path), "agents"))) console.log(`\n! ${p.code}: ${p.message}`);
1434
594
  if (soulName) {
1435
595
  const information = operationalKnowledgeNote(composition, soulName);
1436
596
  if (information) console.log(`\nINFO: ${information}`);
@@ -1448,7 +608,7 @@ function replaceLaunchConfigsBlock(text, serialized) {
1448
608
  const keyLine = /^(["']?)launch-configs\1:(\s*(?:#.*)?|\s+\S.*)?$/;
1449
609
  const lines = text.split("\n");
1450
610
  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" });
611
+ 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
612
  const block = serialized ? serialized.replace(/\n$/, "").split("\n") : [];
1453
613
  if (!starts.length) {
1454
614
  if (!serialized) return text;
@@ -1489,7 +649,7 @@ function serializeLaunchConfigs(map) {
1489
649
  const lines = ["launch-configs:"];
1490
650
  for (const name of names) {
1491
651
  const e = map[name];
1492
- lines.push(` ${name}:`, ` runtime: ${e.runtime}`);
652
+ lines.push(` ${name}:`, ` harness: ${e.harness}`);
1493
653
  if (e.executable !== undefined) lines.push(` executable: ${yamlQuoted(e.executable)}`);
1494
654
  if (e.args?.length) { lines.push(" args:"); for (const a of e.args) lines.push(` - ${yamlQuoted(a)}`); }
1495
655
  const envNames = Object.keys(e.env || {}).sort();
@@ -1503,9 +663,11 @@ function serializeLaunchConfigs(map) {
1503
663
  return lines.join("\n") + "\n";
1504
664
  }
1505
665
  /** Only the declared keys, in canonical order, from a validated entry. */
666
+ /** A configuration as `launch-config set` writes it: `harness` always — a `runtime` (the
667
+ * pre-0.27 name, read either) is written back under its new name (lead call 6). */
1506
668
  function normalizeLaunchConfig(e) {
1507
669
  return {
1508
- runtime: e.runtime,
670
+ harness: Object.hasOwn(e, "harness") ? e.harness : e.runtime,
1509
671
  ...(e.executable !== undefined ? { executable: e.executable } : {}),
1510
672
  ...(e.args?.length ? { args: [...e.args] } : {}),
1511
673
  ...(e.env && Object.keys(e.env).length ? { env: Object.fromEntries(Object.keys(e.env).sort().map((n) => [n, typeof e.env[n] === "string" ? e.env[n] : { fromEnv: e.env[n].fromEnv }])) } : {}),
@@ -1513,11 +675,9 @@ function normalizeLaunchConfig(e) {
1513
675
  ...(e.yolo !== undefined ? { yolo: e.yolo } : {}),
1514
676
  };
1515
677
  }
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);
678
+ function readLaunchConfigsModel(local) {
679
+ const map = local["launch-configs"] || {};
680
+ for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, "oats-local.yaml");
1521
681
  const out = Object.create(null); // a name may be "constructor": membership is own only
1522
682
  for (const [n, e] of Object.entries(map)) out[n] = normalizeLaunchConfig(e);
1523
683
  return out;
@@ -1529,7 +689,7 @@ function readLaunchConfigsModel(file) {
1529
689
  * `set --keep-env`. */
1530
690
  function publicLaunchConfig(e, extra = {}) {
1531
691
  const env = Object.fromEntries(Object.keys(e.env || {}).sort().map((n) => [n, typeof e.env[n] === "string" ? { redacted: true } : { fromEnv: e.env[n].fromEnv }]));
1532
- return { runtime: e.runtime, executable: e.executable ?? null, args: [...(e.args || [])], env, model: e.model ?? null, yolo: e.yolo ?? null, ...extra };
692
+ return { harness: e.harness, executable: e.executable ?? null, args: [...(e.args || [])], env, model: e.model ?? null, yolo: e.yolo ?? null, ...extra };
1533
693
  }
1534
694
  /** The scope a launch-config command reads: --dir (or cwd), a running
1535
695
  * home's recorded context (--home), or a soul's own member context
@@ -1551,10 +711,8 @@ function launchConfigContext(bail) {
1551
711
  if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
1552
712
  const agentsRootFlag = flag("agents-root");
1553
713
  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
714
  let soul;
1557
- try { soul = selectSoul(scopeSouls(ctx, r).souls, String(soulFlag), agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
715
+ try { soul = selectSoul(scopeSouls(ctx).souls, String(soulFlag), agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
1558
716
  return { context: memberContextOf(soul, ctx, flag("dir") !== undefined, bail), selected: { soul: soul.name, agentsRoot: soul.agentsRoot } };
1559
717
  }
1560
718
  return { context: ctx, selected: null };
@@ -1564,38 +722,38 @@ function launchConfigContext(bail) {
1564
722
  * configuration, preflighted, read-only; environment values withheld and
1565
723
  * the prompt named, never the TASK body. */
1566
724
  function launchPreview(bail) {
1567
- const sel = { launchConfig: flag("launch-config"), runtime: flag("runtime"), model: flag("model"), yolo: yoloFlag() };
1568
- for (const k of ["launch-config", "runtime", "model"]) if (flag(k) === true) bail("E_BAD_ARGS", `--${k} needs a value`);
1569
- if (sel.runtime !== undefined && !LAUNCH_RUNTIMES.includes(sel.runtime)) bail("E_BAD_ARGS", `--runtime must be one of ${LAUNCH_RUNTIMES.join(", ")}`);
725
+ for (const k of ["launch-config", "harness", "runtime", "model"]) if (flag(k) === true) bail("E_BAD_ARGS", `--${k} needs a value`);
726
+ const sel = { launchConfig: flag("launch-config"), harness: harnessFlag(), model: flag("model"), yolo: yoloFlag() };
727
+ if (sel.harness !== undefined && !LAUNCH_HARNESSES.includes(sel.harness)) bail("E_BAD_ARGS", `--harness must be one of ${LAUNCH_HARNESSES.join(", ")}`);
1570
728
  const { context, selected } = launchConfigContext(bail);
1571
729
  if (!selected) bail("E_BAD_ARGS", "preview needs --home <abs> (an existing instance) or --soul <name> [--dir <scope>] (a new instance)");
1572
- const selectionGiven = sel.launchConfig !== undefined || sel.runtime !== undefined || sel.model !== undefined || sel.yolo !== undefined;
730
+ const selectionGiven = sel.launchConfig !== undefined || sel.harness !== undefined || sel.model !== undefined || sel.yolo !== undefined;
1573
731
  let meta = null, agentLike, home, instance;
1574
732
  if (selected.home) {
1575
733
  home = selected.home;
1576
- try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch (e) { bail("E_HOME_UNKNOWN", `${home}: ${e.message}`); }
734
+ try { meta = upgradeHomeMeta(JSON.parse(readFileSync(join(home, "instance.json"), "utf8")), home); } catch (e) { bail("E_HOME_UNKNOWN", `${home}: ${e.message}`); }
1577
735
  instance = meta.instance || basename(home);
1578
736
  if (!(meta.launch && typeof meta.launch === "object") && !selectionGiven) {
1579
737
  // 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.
738
+ // described as is. Under a selection the planner refuses it
739
+ // (E_LAUNCH_LEGACY: re-spawn it from the deployment).
1582
740
  let d;
1583
741
  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 });
742
+ jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, harness: null, model: null, yolo: null }, harness: meta.harness, 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
743
  return;
1586
744
  }
1587
745
  const agentsRoot = agentsRootOfHome(home);
1588
746
  const agent = (() => { try { return findAgent(agentsRoot, meta.agent); } catch { return undefined; } })();
1589
- agentLike = agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo };
747
+ agentLike = agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo };
1590
748
  } 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 };
749
+ const soul = scopeSouls(context).souls.find((x) => x.name === selected.soul && x.agentsRoot === selected.agentsRoot);
750
+ agentLike = { harness: soul.harness, model: soul.model };
1595
751
  instance = `${soul.name}-<purpose>`; home = join(selected.agentsRoot, soul.name, "instances", instance);
1596
752
  }
753
+ // A home's recorded capabilities; a new instance's are its spawn's resolution,
754
+ // which a preview of a soul does not prepare (spawn --preview does).
1597
755
  let r;
1598
- try { r = resolveOatsConfig(context, selected.soul || meta?.agent); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
756
+ try { r = meta ? resolvedFromHome(home, meta) : { capabilities: [], launchConfigs: launchConfigsAt(context) }; } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message, e.details); }
1599
757
  // The same planner a start uses, in preview mode: failed checks are listed, nothing is touched.
1600
758
  let plan;
1601
759
  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); }
@@ -1603,47 +761,53 @@ function launchPreview(bail) {
1603
761
  const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
1604
762
  const d = describeLaunchCommand(command);
1605
763
  const environment = d.environment.map((e) => e.reference && recipe.env[e.name]?.fromEnv ? { name: e.name, fromEnv: recipe.env[e.name].fromEnv } : e);
1606
- 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 });
764
+ jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, harness: sel.harness ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, harness: plan.harness, 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
765
  }
1608
766
  async function launchConfigCmd() {
1609
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
767
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1610
768
  dropAmbientRoot();
1611
769
  const sub = args[1];
1612
- 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";
770
+ 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] [--harness r] [--model m] [--yolo|--no-yolo] --json";
1613
771
  if (sub === "preview") { launchPreview(bail); return; }
1614
772
  if (!["list", "set", "remove"].includes(sub)) bail("E_USAGE", usage);
1615
773
  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
- };
774
+ 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`);
775
+ // Lead decision 2: launch configurations are a HOST choice, declared in the
776
+ // deployment's oats-local.yaml (found walking up; a home's own deployment).
777
+ const at = selected?.home ?? dir;
778
+ // A 0.25 oats-config.yaml in reach is refused by loadLocal (E_CONFIG_BROKEN, naming
779
+ // the move), never read as "no configurations".
780
+ let found = null;
781
+ // No deployment in reach answers the (empty) effective set — unless what is in
782
+ // reach is a 0.25 oats-config.yaml, whose launch-configs nothing reads any more.
783
+ 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); }
784
+ const file = found?.path ?? null, level = file ? dirname(file) : null;
785
+ 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
786
  if (sub === "list") {
1624
787
  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; }
788
+ try { configurations = effective(); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
789
+ if (JSON_MODE) { jsonOk({ context: dir, level, file, selected, configurations }); return; }
790
+ if (!configurations.length) { console.log(`No launch configurations are declared${file ? ` in ${shortPath(file)}` : ` (no oats-local.yaml in reach of ${dir})`}`); return; }
1628
791
  for (const c of configurations) {
1629
792
  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(", ")}` : ""})`);
793
+ console.log(`${c.name}: ${c.harness}${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
794
  }
1632
795
  return;
1633
796
  }
797
+ 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
798
  const name = args[2];
1635
799
  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`;
800
+ const text = readFileSync(file, "utf8");
1637
801
  let model;
1638
- try { model = readLaunchConfigsModel(file); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
802
+ try { model = readLaunchConfigsModel(found.local); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
1639
803
  const declaredHere = Object.hasOwn(model, name);
1640
804
  const before = declaredHere ? publicLaunchConfig(model[name]) : null;
1641
805
  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`);
806
+ if (!declaredHere) bail("E_LAUNCH_CONFIG_UNKNOWN", `${name} is not declared in ${shortPath(file)}`);
1643
807
  delete model[name];
1644
808
  } else {
1645
809
  const f = flag("file");
1646
- if (!f || f === true) bail("E_BAD_ARGS", "launch-config set needs --file <json> (an object with runtime and optional executable, args, env, model, yolo)");
810
+ if (!f || f === true) bail("E_BAD_ARGS", "launch-config set needs --file <json> (an object with harness and optional executable, args, env, model, yolo)");
1647
811
  let entry;
1648
812
  // A parse error is reported without the parser's text: its message can
1649
813
  // quote the document, and a definition may carry environment literals.
@@ -1656,19 +820,18 @@ async function launchConfigCmd() {
1656
820
  } else {
1657
821
  try { raw = readFileSync(f, "utf8"); } catch (e) { bail("E_BAD_ARGS", `--file ${f}: ${e.code === "ENOENT" ? "no such file" : e.code || "cannot read"}`); }
1658
822
  }
1659
- 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)`); }
823
+ try { entry = JSON.parse(raw); } catch { bail("E_BAD_ARGS", `--file ${f} is not valid JSON (one object with harness and optional executable, args, env, model, yolo)`); }
1660
824
  if (args.includes("--keep-env")) {
1661
825
  // 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`);
826
+ // declared definition of that name: a one-time copy into the complete
827
+ // replacement entry.
828
+ 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");
829
+ const current = declaredHere ? model[name] : undefined;
830
+ 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
831
  if (entry && typeof entry === "object" && Object.keys(current.env || {}).length) entry.env = { ...current.env };
1670
832
  }
1671
833
  try { validateLaunchConfig(name, entry, `--file ${f}`); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
834
+ if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`runtime in the --file definition (written as harness)`);
1672
835
  model[name] = normalizeLaunchConfig(entry);
1673
836
  }
1674
837
  let next;
@@ -1676,7 +839,10 @@ async function launchConfigCmd() {
1676
839
  // What is written must read back as exactly what was asked, by the kernel's
1677
840
  // own reader, before a byte of the file changes.
1678
841
  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`); }
842
+ let nextLocal;
843
+ 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`); }
844
+ const schemaProblems = validateLocal(nextLocal);
845
+ 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
846
  const canonical = (m) => JSON.stringify(Object.keys(m).sort().map((n) => [n, normalizeLaunchConfig(m[n])]));
1681
847
  const same = canonical(readBack) === canonical(model);
1682
848
  if (!same) bail("E_LAUNCH_CONFIG_INVALID", `${name} would not read back as written; nothing was written`);
@@ -1685,7 +851,7 @@ async function launchConfigCmd() {
1685
851
  try { eff = effective().find((c) => c.name === name) || null; } catch (e) { eff = { error: e.message }; }
1686
852
  const receipt = { name, action: sub, level, file, before, after: Object.hasOwn(model, name) ? publicLaunchConfig(model[name]) : null, effective: eff };
1687
853
  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` : ""}`);
854
+ console.log(sub === "set" ? `Declared launch configuration ${name} in ${shortPath(file)}` : `Removed launch configuration ${name} from ${shortPath(file)}`);
1689
855
  }
1690
856
 
1691
857
 
@@ -1752,8 +918,7 @@ function instanceCmd() {
1752
918
  if (home === undefined) {
1753
919
  let root;
1754
920
  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)))];
921
+ const roots = [realOrResolved(root)];
1757
922
  const candidates = [];
1758
923
  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
924
  if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
@@ -1783,48 +948,36 @@ function instanceCmd() {
1783
948
  bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
1784
949
  }
1785
950
  }
1786
- /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
1787
- function readinessCmd() {
951
+ /** `oats readiness (--soul <name> [--agents-root <abs>] [--dir <d>] | --home <abs>) [--policy] --json` — readinessApi 2. */
952
+ async function readinessCmd() {
1788
953
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1789
954
  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
955
  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}` : ""}`);
956
+ // Workspace model (readinessApi 2): an instance or soul subject; checks
957
+ // installed | configured | member | providers. No trusted check.
958
+ 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");
959
+ const target = await workspaceTarget(bail, { command: "readiness" });
960
+ {
961
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
962
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul: given("soul"), agentsRoot: given("agents-root") }
963
+ : { kind: "soul", soul: String(flag("soul")), agentsRoot: given("agents-root"), dir: given("dir") };
964
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
965
+ const doc = await readinessDocument(target, { selector, remoteOptions: remoteOptionsFromEnv(), catalog });
966
+ if (args.includes("--policy")) {
967
+ doc.policy = policyOf({ instanceMeta: target.meta, soul: target.meta ? null : policySoul(target) }).policy;
968
+ doc.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
969
+ }
970
+ if (JSON_MODE) { jsonOk(doc); return; }
971
+ const s = doc.subject;
972
+ 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`}`);
973
+ for (const [name, check] of Object.entries(doc.checks)) {
974
+ console.log(` ${name}: ${check.status}`);
975
+ 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}` : ""}`);
976
+ }
977
+ 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"}`);
978
+ for (const n of doc.notes) console.log(` note: ${n}`);
979
+ return;
1825
980
  }
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
981
  }
1829
982
  // ---------- workspace model v2: sync / package / workspace status / capabilities / souls ----------
1830
983
  // Contract: docs/design/2026-09-23-workspace-module-contracts.md §6. Nothing is
@@ -1862,12 +1015,13 @@ function catalogForSync(bail) {
1862
1015
  * recomposes the tag from the catalog's own convention.
1863
1016
  * → { packages: { <id>: <version> }, problems: [ { code: "E_PACKAGE_MISSING", … } ] } */
1864
1017
  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 */ }
1018
+ let id = "oats.framework";
1019
+ const file = officialCatalogFile();
1020
+ try { const alias = officialCapabilityAliases()["oats.core"]; id = (typeof alias === "string" ? alias : alias?.package) ?? id; } catch { /* the catalog is diagnosed below */ }
1867
1021
  const version = standaloneCatalogVersion(catalog?.[id]?.ref);
1868
1022
  if (version) return { packages: { [id]: version }, problems: [] };
1869
1023
  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` }] };
1024
+ 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
1025
  }
1872
1026
  /** The bare version of a catalog ref: its LAST path segment when that is a version
1873
1027
  * (`v1.1.3` → `v1.1.3`; `oats-framework/v1.1.3` → `v1.1.3`; `main` → null). */
@@ -1889,31 +1043,29 @@ async function discoverForCli(ctx, bail) {
1889
1043
  }
1890
1044
 
1891
1045
  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
1046
  /** Display name of a discovery: the workspace's name, or the standalone label (decision 10). */
1897
1047
  const workspaceName = (discovery) => discovery.workspace?.name ?? `standalone:${memberLabel(discovery.key)}`;
1898
1048
  const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || String(key);
1899
1049
  const teamLabel = (team) => team ?? "unassigned";
1900
1050
  const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1901
1051
 
1902
- /** Rows of every non-private soul/capability of confirmed members + locked package capabilities. */
1903
- function workspaceItems(discovery, lock, { includePrivate = false } = {}) {
1052
+ /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1053
+ * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1054
+ * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE). */
1055
+ function workspaceItems(discovery, lock) {
1904
1056
  const souls = [];
1905
1057
  const capabilities = [];
1906
1058
  for (const m of discovery.members) {
1907
1059
  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 });
1060
+ 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 });
1061
+ 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
1062
  }
1911
1063
  for (const ext of discovery.external || []) {
1912
1064
  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 });
1065
+ 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
1066
  }
1915
1067
  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 });
1068
+ 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
1069
  }
1918
1070
  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
1071
  souls.sort(byName);
@@ -1931,7 +1083,7 @@ function memberRows(discovery) {
1931
1083
 
1932
1084
  /** Package rows for `sync` / `workspace status` from the lock. */
1933
1085
  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 }));
1086
+ 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
1087
  }
1936
1088
 
1937
1089
  /** Print a padded table: rows are arrays of strings. */
@@ -1941,55 +1093,17 @@ function printTable(header, rows) {
1941
1093
  for (const r of all) console.log(" " + r.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1942
1094
  }
1943
1095
 
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
- }
1096
+ /** `--approve` left with package approval (human decision 2026-09-24). */
1097
+ const APPROVAL_REMOVED = "package approval was removed; declaring a package in packages: is the trust decision";
1986
1098
 
1987
1099
  /** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
1988
1100
  * 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.
1101
+ * `packages:` against the lock (commit + integrity), write the lock. There is no approval step:
1102
+ * declaring a package in `packages:` is the trust decision (human decision 2026-09-24).
1990
1103
  * `bail` never returns (it exits the process with the caller's error shape).
1991
- * → { report, lock, discovery, approvalNeeded, interactive, items, lockFile } */
1104
+ * → { report, lock, discovery, items, lockFile, problems } */
1992
1105
  async function performSync(ctx, bail, { onDiscovered } = {}) {
1106
+ if (args.includes("--approve")) return bail("E_BAD_ARGS", `--approve: ${APPROVAL_REMOVED}`, { flag: "--approve" });
1993
1107
  const catalog = catalogForSync(bail);
1994
1108
  const discovery = await discoverForCli(ctx, bail);
1995
1109
  onDiscovered?.(discovery);
@@ -2007,37 +1121,7 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
2007
1121
  if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
2008
1122
  throw e;
2009
1123
  }
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
- }
1124
+ const lock = resolved.lock;
2041
1125
  let lockFile;
2042
1126
  try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2043
1127
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
@@ -2046,9 +1130,9 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
2046
1130
  const members = memberRows(discovery);
2047
1131
  const packages = packageRows(lock);
2048
1132
  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 };
1133
+ const items = workspaceItems(discovery, lock);
1134
+ 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 ?? [] };
1135
+ return { report, lock, discovery, items, lockFile, problems };
2052
1136
  }
2053
1137
 
2054
1138
  /** The one-line standalone explanation (decision 10). `standaloneReason` says WHY the view is
@@ -2065,32 +1149,28 @@ function standaloneNote(discovery, { from = "" } = {}) {
2065
1149
 
2066
1150
  /** The human §8 report of a sync (text mode). */
2067
1151
  function printSyncReport(ctx, synced) {
2068
- const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
1152
+ const { report, discovery, items, lockFile } = synced;
2069
1153
  const { members, packages, changes } = report;
2070
1154
  const disabled = new Set(ctx.local.souls?.disabled || []);
2071
1155
  console.log(`workspace ${discovery.workspace?.name ?? `(${standaloneNote(discovery)})`} (${discovery.key} @ ${short(discovery.commit)})`);
2072
1156
  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)"}`);
1157
+ console.log(`packages ${packages.map((p) => `${p.id} ${p.version} ✓ (@ ${short(p.commit)})`).join(" ") || "(none)"}`);
2077
1158
  const changed = changes.filter((c) => c.to !== null && c.from !== c.to).map((c) => `${c.id} ${c.from ?? "—"} → ${c.to} (@ ${short(c.commit)})`);
2078
1159
  const removed = changes.filter((c) => c.to === null).map((c) => `${c.id} ${c.from} → removed`);
2079
1160
  console.log(`changed ${[...changed, ...removed].join(" ") || "(nothing — the lock already described this workspace)"}`);
2080
1161
  const memberSouls = items.souls.filter((s) => s.kind === "member");
2081
1162
  const externalSouls = items.souls.filter((s) => s.kind === "external");
2082
- const privateSouls = memberSouls.filter((s) => s.private);
1163
+ // Souls have no private mode (0.26.0); the private count is of repo-owned member capabilities.
1164
+ const repoOwned = items.capabilities.filter((c) => c.kind === "member" && c.private);
2083
1165
  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("; ")})` : ""}`);
1166
+ 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
1167
  const teams = new Map();
2086
1168
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
2087
1169
  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
1170
  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
1171
  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)}`);
1172
+ for (const w of discovery.warnings ?? []) console.log(`warning ${w.code} ${w.message}`);
1173
+ console.log(`\nlock ${shortPath(lockFile)}`);
2094
1174
  }
2095
1175
 
2096
1176
  /** `oats sync [--dir] [--json]` — contract §6. */
@@ -2098,10 +1178,8 @@ async function syncCmd() {
2098
1178
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2099
1179
  const ctx = workspaceContext(bail);
2100
1180
  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.
1181
+ // One envelope (or the §8 report); success is exit 0 (a failure bails with its code).
2103
1182
  if (JSON_MODE) jsonOk(synced.report); else printSyncReport(ctx, synced);
2104
- process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2105
1183
  }
2106
1184
 
2107
1185
  /** Walk up from dir for oats-workspace.yaml INSIDE a Git checkout → { file, root } | null. */
@@ -2186,7 +1264,7 @@ async function packageCmd() {
2186
1264
  const receipt = { action: sub, id, value: value ?? null, previous: had ?? null, edited: true, file: checkout.file };
2187
1265
  if (JSON_MODE) { jsonOk(receipt); return; }
2188
1266
  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).`
1267
+ ? `${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
1268
  : `Removed packages.${id} (${had}) from ${shortPath(checkout.file)}. Commit it, then \`oats sync\`.`);
2191
1269
  }
2192
1270
 
@@ -2207,8 +1285,7 @@ async function workspaceCmd() {
2207
1285
  const locked = new Set(packages.map((p) => p.id));
2208
1286
  const unsynced = declared.filter((id) => !locked.has(id));
2209
1287
  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 };
1288
+ 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
1289
  if (JSON_MODE) { jsonOk(result); return; }
2213
1290
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
2214
1291
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
@@ -2217,11 +1294,12 @@ async function workspaceCmd() {
2217
1294
  for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
2218
1295
  console.log("\nPackages:");
2219
1296
  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(",")]));
1297
+ else printTable(["package", "version", "source", "commit", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.capabilities.join(",")]));
2221
1298
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
2222
1299
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
2223
1300
  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
1301
  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}`); }
1302
+ if (discovery.warnings?.length) { console.log("\nWarnings:"); for (const w of discovery.warnings) console.log(` ${w.code} ${w.message}`); }
2225
1303
  }
2226
1304
 
2227
1305
  /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
@@ -2236,8 +1314,8 @@ async function itemsCmd(kind) {
2236
1314
  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
1315
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
2238
1316
  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 ?? "—"]));
1317
+ 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 ?? "—"]));
1318
+ else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.private ? `${c.name} (repo-owned)` : c.name, c.origin, c.team, c.layer ?? "—"]));
2241
1319
  const unsynced = Object.keys(discovery.workspace?.packages || {}).filter((id) => !lock.packages[id]);
2242
1320
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
2243
1321
  }
@@ -2252,7 +1330,13 @@ async function itemsCmd(kind) {
2252
1330
  * present) to its stamp — the roster's `repo:` column for a workspace soul. */
2253
1331
  async function statusDrift(data) {
2254
1332
  let ctx;
2255
- try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
1333
+ try { ctx = loadLocal(dirFlag()); }
1334
+ catch (e) {
1335
+ if (e?.code === "E_LOCAL_MISSING") return null;
1336
+ // A 0.25 oats-config.yaml inside the deployment: the typed migration error, never a stack.
1337
+ if (e?.code === "E_CONFIG_BROKEN") { if (JSON_MODE) jsonFail(e.code, e.message, e.details); die(e.message); }
1338
+ throw e;
1339
+ }
2256
1340
  const hasModules = (i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length > 0;
2257
1341
  const hasSoul = (i) => i.workspace && typeof i.workspace === "object" && i.workspace.soul && typeof i.workspace.soul === "object" && typeof i.workspace.soul.repoKey === "string";
2258
1342
  // Workspace souls of this roster: the stamp ensureWorkspaceSoul leaves beside the soul pointer.
@@ -2316,13 +1400,14 @@ function soulRepoLabel(a, ws) {
2316
1400
  const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
2317
1401
 
2318
1402
  async function status() {
2319
- if (args.includes("--team")) return statusTeam();
1403
+ 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
1404
  let root;
2321
1405
  try { root = ensureRoot(dirFlag()); }
2322
1406
  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
1407
  const data = listInstances(root);
2324
1408
  const ws = await statusDrift(data);
2325
1409
  const verbose = args.includes("--verbose");
1410
+ const problems = legacyLayoutProblems(root);
2326
1411
  if (args.includes("--json")) {
2327
1412
  if (ws) for (const a of data) {
2328
1413
  const stamp = ws.souls.get(a.name);
@@ -2335,13 +1420,14 @@ async function status() {
2335
1420
  if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}) };
2336
1421
  }
2337
1422
  }
2338
- console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
1423
+ console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
2339
1424
  }
2340
1425
  console.log(`oats status — agents root ${shortPath(root)}\n`);
2341
1426
  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; }
1427
+ for (const p of problems) console.log(` ! ${p.code}: ${p.message}\n`);
1428
+ 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
1429
  for (const a of data) {
2344
- console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
1430
+ console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
2345
1431
  if (a.description) console.log(` ${a.description}`);
2346
1432
  for (const i of a.instances) {
2347
1433
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
@@ -2356,29 +1442,6 @@ async function status() {
2356
1442
  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
1443
  }
2358
1444
  }
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
1445
  }
2383
1446
 
2384
1447
  async function spawnCmd() {
@@ -2393,90 +1456,96 @@ async function spawnCmd() {
2393
1456
  const checkDirectoryOptions = (work) => {
2394
1457
  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
1458
  };
2396
- checkDirectoryOptions(requestedWork); // before a local soul could be upserted
1459
+ checkDirectoryOptions(requestedWork); // before anything is resolved or written
2397
1460
  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]");
1461
+ 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>] [--harness pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--no-launch] [--json]");
2399
1462
  // 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)");
1463
+ // ANY side effect, including root discovery.
1464
+ // Local souls (local-agents/) are gone with the workspace model: a soul is a member
1465
+ // repository's souls/<name>, so spawn never writes one.
1466
+ 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\``);
1467
+ 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)");
1468
+ // --name <slug>: the exact, unprefixed instance name (human decision 2026-09-24).
1469
+ const nameFlag = flag("name");
1470
+ if (nameFlag === true) bail("E_BAD_ARGS", "--name needs an instance name (a slug: lowercase letters and digits, single dashes between them)");
1471
+ 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>");
1472
+ // The slug rule is checked here too, before any side effect (soul fetch).
1473
+ if (nameFlag !== undefined) { try { explicitInstanceName(String(nameFlag)); } catch (e) { bail(e.code, e.message); throw e; } }
2404
1474
  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
1475
  let root;
1476
+ // A spawn needs a workspace deployment (lead decision c3-1): no oats-local.yaml
1477
+ // in reach is E_LOCAL_MISSING, before anything else is read. The agents root is
1478
+ // then <deployment>/agents; an ambient root (the invoking agent's own
1479
+ // PI_AGENTS_ROOT / OATS_ROOT) never redirects it — a home outside
1480
+ // <deployment>/agents would have no derivable deployment.
1481
+ 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); }
1482
+ delete process.env.PI_AGENTS_ROOT; delete process.env.OATS_ROOT;
1483
+ // A deployment without its agents/ root is E_NO_DEPLOYMENT, naming the remedy.
2406
1484
  try { root = ensureRoot(dirFlag()); }
2407
1485
  catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
2408
1486
  const isPreview = args.includes("--preview");
2409
1487
  // --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.
1488
+ // readiness take it) — the deployment's one agents root, or E_SOUL_UNKNOWN.
2412
1489
  const agentsRootFlag = flag("agents-root");
2413
1490
  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
- }
1491
+ 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
1492
  let agent = findAgent(root, name);
2420
1493
  // 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
1494
+ // remotes and resolved (member = latest state, package = locked) — never
2422
1495
  // "whatever <agents-root>/<name>/soul/ happens to hold": that copy is a per-commit
2423
1496
  // cache (ensureWorkspaceSoul refreshes it when the member moved), so a second
2424
1497
  // 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).
1498
+ // same read-only discovery+resolution and writes nothing: it reads the soul from
1499
+ // the per-commit cache, or fetches it to a temporary copy (reported as soulFetched).
2427
1500
  const providerPairs = [];
2428
1501
  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;
1502
+ let wsPrepared, soulFetched = false, wsSoulUnknown = null, wsDiscovery;
2431
1503
  {
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;
1504
+ let discovery = null;
1505
+ try {
1506
+ const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1507
+ const remoteOptions = remoteOptionsFromEnv();
1508
+ const { local } = loadLocal(dirFlag());
1509
+ discovery = wsDiscovery = await discoverOrStandalone(local, { remoteOptions });
1510
+ wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
1511
+ const soulName = wsPrepared.soulEntry.name;
1512
+ let soulDir;
1513
+ if (isPreview) {
1514
+ // A preview writes nothing in the deployment: the soul comes from the
1515
+ // per-commit cache when complete, else from a temporary fetch removed at exit.
1516
+ const pv = await previewWorkspaceSoul(wsPrepared, root);
1517
+ process.once("exit", pv.cleanup);
1518
+ soulDir = pv.soulDir; soulFetched = pv.fetched;
1519
+ agent = findAgentAt(root, soulName, soulDir);
1520
+ } else {
2442
1521
  const stampFile = join(root, soulName, ".oats-soul-source.json");
2443
1522
  const stampBefore = (() => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } })();
2444
- const soulDir = await ensureWorkspaceSoul(wsPrepared, root);
1523
+ soulDir = await ensureWorkspaceSoul(wsPrepared, root);
2445
1524
  soulFetched = !stampBefore || stampBefore.commit !== wsPrepared.soulEntry.commit || stampBefore.repoKey !== wsPrepared.soulEntry.repoKey;
2446
1525
  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
1526
  }
2464
- } else if (providerPairs.length) bail("E_BAD_ARGS", "--provider needs a workspace deployment (oats-local.yaml); this directory has none");
1527
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
1528
+ 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" : ""})`);
1529
+ } catch (e) {
1530
+ // Standalone (decisions 10/25): the ONLY package request is the kernel's own
1531
+ // default; when the catalog cannot name it, say so instead of "add it to packages:"
1532
+ // (there is no workspace file to add it to).
1533
+ if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
1534
+ const file = officialCatalogFile();
1535
+ 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 });
1536
+ }
1537
+ // Not a workspace soul: a capability-defined agent (a module's `agents:`
1538
+ // soul, resolved below from a materialized copy) may still answer to this name.
1539
+ if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
1540
+ else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
1541
+ else throw e;
1542
+ }
2465
1543
  }
2466
1544
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
2467
1545
  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) {
1546
+ // Capability agent (lead decision c3 Q1): a prepared spawn of its providing module only.
1547
+ let capabilityPrepared, capabilityPkg = null;
1548
+ if (!agent) {
2480
1549
  // Workspace model: the agent is declared by a capability some INSTANCE already
2481
1550
  // materialized (the --parent home first, then any home under this root) —
2482
1551
  // OKF's memory-harvest worker spawned by a knowledge source, for example.
@@ -2487,50 +1556,30 @@ async function spawnCmd() {
2487
1556
  catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
2488
1557
  if (modAgent) {
2489
1558
  agent = modAgent;
2490
- note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home locally)`);
1559
+ note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`);
2491
1560
  } else {
2492
- // No instance carries it: resolve from the deployment's LOCK — an approved
1561
+ // No instance carries it: resolve from the deployment's LOCK — a locked
2493
1562
  // package whose capability declares agents/<name> is fetched into the
2494
1563
  // deployment's module store and read from there.
2495
1564
  try {
2496
1565
  const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
2497
- const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1566
+ const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), discovery: wsDiscovery, catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
2498
1567
  if (hit) {
2499
1568
  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)`);
1569
+ 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
1570
  }
2502
1571
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2503
1572
  }
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)`);
1573
+ if (agent) {
1574
+ try {
1575
+ const { prepareCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1576
+ capabilityPrepared = await prepareCapabilityAgent(dirFlag(), agent, { discovery: wsDiscovery, pkg: capabilityPkg, remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1577
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2516
1578
  }
2517
1579
  }
1580
+ if (!agent && wsSoulUnknown) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
2518
1581
  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
- }
1582
+ 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
1583
  for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
2535
1584
  // Lineage is explicit: --relation child|sibling|parent|unrelated anchors the new
2536
1585
  // instance to --relative-to <instance>. --parent X is sugar for
@@ -2558,9 +1607,9 @@ async function spawnCmd() {
2558
1607
  // NOTE: explicit "unrelated" is passed through to the kernel.
2559
1608
  if (relativeTo && relation !== "unrelated") {
2560
1609
  // findInstanceHome also sees capability-defined agents' instance homes
2561
- // (local-agents/<name>/ without a local soul) — e.g. a reviewer passing
1610
+ // (<root>/<name>/ without a soul) — e.g. a reviewer passing
2562
1611
  // --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`);
1612
+ 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
1613
  }
2565
1614
  const taskText = flag("task");
2566
1615
  if (taskText === true) bail("E_BAD_ARGS", "--task needs a value (use --task-file for long tasks)");
@@ -2600,7 +1649,7 @@ async function spawnCmd() {
2600
1649
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2601
1650
  // Workspace model: when this deployment has an oats-local.yaml, the soul's
2602
1651
  // capabilities are resolved over the workspace's remotes (member = latest,
2603
- // package = locked+approved) and copied whole into the new home. Without one
1652
+ // package = locked) and copied whole into the new home. Without one
2604
1653
  // (a bare agents root, tests) the classic soul-directory spawn proceeds.
2605
1654
  let prepared;
2606
1655
  // Workspace model: a `work: worktree|checkout` soul works IN its member's clone on
@@ -2608,10 +1657,10 @@ async function spawnCmd() {
2608
1657
  // convention <deployment>/<member name>. Never an ambient Git checkout around the
2609
1658
  // deployment. Resolved once here so a preview sees exactly what the apply would.
2610
1659
  let preparedRepo;
2611
- if (wsPrepared) {
1660
+ if (wsPrepared || capabilityPrepared) {
2612
1661
  try {
2613
1662
  const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
2614
- prepared = wsPrepared;
1663
+ prepared = wsPrepared ?? capabilityPrepared;
2615
1664
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
2616
1665
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
2617
1666
  prepared.toCapabilityRows = toCapabilityRows;
@@ -2625,18 +1674,19 @@ async function spawnCmd() {
2625
1674
  if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
2626
1675
  { const spawnOpts = {
2627
1676
  prepared,
2628
- purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
1677
+ purpose: flag("purpose"), ...(nameFlag !== undefined ? { name: String(nameFlag) } : {}), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
2629
1678
  ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
2630
1679
  // Directory execution uses deployment configuration, not an ambient Git
2631
1680
  // 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()),
2634
- work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
1681
+ // An attached instance's repository is its work tree owner's (derived by the kernel).
1682
+ repo: preparedRepo !== undefined ? preparedRepo : ["directory", "attached"].includes(requestedWork || agent.work)
1683
+ ? repo : repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
1684
+ work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
2635
1685
  launchConfig: valueFlag("launch-config"),
2636
1686
  launch: !args.includes("--no-launch"),
2637
1687
  // K6: --preview decides everything and touches nothing; --base <ref>
2638
1688
  // selects a worktree's start point; --model @native-default is the
2639
- // explicit "runtime's own default" (distinct from omitting --model).
1689
+ // explicit "harness's own default" (distinct from omitting --model).
2640
1690
  ...(args.includes("--preview") ? { preview: true, subject: { soul: name, agentsRoot: agentsRootFlag !== undefined ? String(agentsRootFlag) : null, dir: flag("dir") !== undefined && flag("dir") !== true ? String(flag("dir")) : null } } : {}),
2641
1691
  ...(flag("base") !== undefined && flag("base") !== true ? { baseRef: flag("base") } : {}),
2642
1692
  // A confirmed preview binds this apply (K6b): drift → E_DECISION_STALE, nothing created.
@@ -2644,13 +1694,13 @@ async function spawnCmd() {
2644
1694
  // K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
2645
1695
  ...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
2646
1696
  };
2647
- r = prepared ? await spawnInstanceAsync(root, agent, spawnOpts) : spawnInstance(root, agent, spawnOpts); }
1697
+ r = await spawnInstanceAsync(root, agent, spawnOpts); }
2648
1698
  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.
1699
+ // A workspace preview may have fetched the soul's SOURCE to a temporary copy
1700
+ // (the deployment's cache had no entry for its commit): the result says so.
2651
1701
  if (prepared) r.soulFetched = soulFetched;
2652
1702
  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)` : ""}`);
1703
+ console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) harness ${r.harness}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? " (the soul source was fetched to a temporary copy, not kept)" : ""}`);
2654
1704
  return;
2655
1705
  }
2656
1706
  } catch (e) {
@@ -2660,9 +1710,9 @@ async function spawnCmd() {
2660
1710
  // document the message already names. The shared boundary renders it.
2661
1711
  if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
2662
1712
  // A launch refusal (configuration, executable, environment reference,
2663
- // model, runtime) is a fact about the selection, not a spawn-mechanism
1713
+ // model, harness) is a fact about the selection, not a spawn-mechanism
2664
1714
  // failure: it keeps its own code so a GUI can act on it.
2665
- if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_RUNTIME$/.test(e.code)) { bail(e.code, e.message); throw e; }
1715
+ if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_HARNESS$/.test(e.code)) { bail(e.code, e.message); throw e; }
2666
1716
  // An unmet declared requirement is a fact about the soul's configuration
2667
1717
  // (with a remedy), not a spawn-mechanism failure: keep its code and details.
2668
1718
  if (e?.code === "E_REQUIREMENT_INACTIVE") { bail(e.code, e.message, { soul: e.soul, capabilities: e.capabilities, context: e.context, remedy: e.remedy }); throw e; }
@@ -2672,6 +1722,8 @@ async function spawnCmd() {
2672
1722
  if (e?.code === "E_DECISION_STALE") { bail(e.code, e.message, { decision: e.decision }); throw e; }
2673
1723
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
2674
1724
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1725
+ 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; }
1726
+ if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message); throw e; }
2675
1727
  if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
2676
1728
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
2677
1729
  }
@@ -2695,7 +1747,7 @@ async function spawnCmd() {
2695
1747
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2696
1748
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2697
1749
  ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2698
- tmux: r.tmux || null, repo: r.repo || null, runtime: r.runtime || null,
1750
+ tmux: r.tmux || null, repo: r.repo || null, harness: r.harness || null,
2699
1751
  model: r.model || null, parent: r.parentInstance || null,
2700
1752
  sibling: r.siblingInstance || null, relation: r.relation || null,
2701
1753
  spawnOrigin: r.spawnOrigin, attach: r.attach,
@@ -2734,7 +1786,7 @@ function retireCmd() {
2734
1786
  console.log(` defaults: retain worktree ${plan.defaults.retainWorktree}, delete branch ${plan.defaults.deleteBranch}, stop children ${plan.defaults.stopChildren}`);
2735
1787
  for (const n of plan.notes) console.log(` note: ${n}`);
2736
1788
  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); }
1789
+ } 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
1790
  }
2739
1791
  // The calling instance knows its own home: self-retire never needs to
2740
1792
  // disambiguate a same-named twin by hand.
@@ -2742,13 +1794,7 @@ function retireCmd() {
2742
1794
  const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
2743
1795
  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
1796
  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
- }
1797
+ const root = ensureRoot(dirFlag());
2752
1798
  const retiringHome = homeFlag || findInstanceHome(root, name);
2753
1799
  // K3: a GUI-driven Remove carries the plan revision it showed and an
2754
1800
  // idempotency key. The revision is revalidated against a fresh plan
@@ -2765,7 +1811,7 @@ function retireCmd() {
2765
1811
  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
1812
  for (const a of listAgents(root)) if (replay(join(a._dir, "instances"))) return;
2767
1813
  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); }
1814
+ 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
1815
  replayPath = join(dirname(fresh.home), `.oats-retire-receipt.${idemKey}.json`);
2770
1816
  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
1817
  // The plan promised: recorded children are STOPPED first (bounded, never
@@ -2780,7 +1826,9 @@ function retireCmd() {
2780
1826
  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
1827
  expectedBranch = fresh.facts.work.observed ? fresh.facts.work.branch : undefined;
2782
1828
  }
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 } : {}) });
1829
+ let r;
1830
+ 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 } : {}) }); }
1831
+ 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
1832
  if (childrenStopped) r.childrenStopped = childrenStopped;
2785
1833
  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
1834
  // A retired home's wake jobs are forgotten (definitions only; nothing is
@@ -2792,7 +1840,7 @@ function retireCmd() {
2792
1840
  if (r.deferred) {
2793
1841
  if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); return; }
2794
1842
  console.log(`Retirement of ${r.retired} (agent ${r.agent}) is ${r.alreadyScheduled ? "already " : ""}scheduled — say any goodbyes now.`);
2795
- console.log(` in ~${r.completesInSec}s a detached completion quiesces this runtime (that is what ends this window), preserves work, runs retire hooks and removes the home`);
1843
+ console.log(` in ~${r.completesInSec}s a detached completion quiesces this harness (that is what ends this window), preserves work, runs retire hooks and removes the home`);
2796
1844
  console.log(` if the completion fails, this window stays, the failure shows in \`oats status\` and at ${shortPath(r.resultPath)}, and \`oats retire ${r.retired}\` retries it`);
2797
1845
  return;
2798
1846
  }
@@ -2818,8 +1866,10 @@ function retireCmd() {
2818
1866
  // which is most of the harm of deleting it. Name the classes and the path.
2819
1867
  for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
2820
1868
  console.log(`Work that was not committed has been preserved: ${recovery.classes.join(", ")}`);
2821
- console.log(` ${recovery.path}`);
1869
+ console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
1870
+ for (const line of preservedOutputLines(recovery)) console.log(line);
2822
1871
  }
1872
+ for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2823
1873
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2824
1874
  }
2825
1875
 
@@ -2829,9 +1879,11 @@ function retireCmd() {
2829
1879
  function scheduleCmd() {
2830
1880
  const sub = args[1];
2831
1881
  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());
1882
+ // One schedule-owning scope for a directory: its deployment (the directory
1883
+ // holding oats-local.yaml), resolved when a subcommand needs it — inside the
1884
+ // try, so no deployment in reach is a typed refusal (E_LOCAL_MISSING).
1885
+ let scope;
1886
+ const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
2835
1887
  const io = { hostStatus: () => hostUnitStatus() };
2836
1888
  const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2837
1889
  const readSpec = () => {
@@ -2845,41 +1897,41 @@ function scheduleCmd() {
2845
1897
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
2846
1898
  try {
2847
1899
  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") }));
1900
+ case "list": return out(listSchedules(ws(), io));
1901
+ case "show": return out({ schedule: describeSchedule(ws(), needId(), io) });
1902
+ 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) }); }
1903
+ case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
1904
+ case "enable": return out({ schedule: setScheduleEnabled(ws(), needId(), true, io) });
1905
+ case "disable": return out({ schedule: setScheduleEnabled(ws(), needId(), false, io) });
1906
+ case "run": return out(runScheduleNow(ws(), needId(), { io, force: args.includes("--force") }));
1907
+ case "remove": return out(removeSchedule(ws(), needId(), { force: args.includes("--force") }));
1908
+ case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear") }));
2857
1909
  case "tick": {
2858
1910
  const dryRun = args.includes("--dry-run");
2859
1911
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
2860
1912
  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) });
1913
+ const considered = withHostLock(() => tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun }));
1914
+ return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
2863
1915
  }
2864
1916
  case "host": {
2865
1917
  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) });
1918
+ if (op === "install") { registerWorkspace(ws()); installHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
1919
+ if (op === "uninstall") { unregisterWorkspace(ws()); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws(), io) }); }
1920
+ if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
2869
1921
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2870
1922
  }
2871
1923
  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
1924
  }
2873
1925
  } catch (e) {
2874
1926
  // 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]]));
1927
+ 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
1928
  if (JSON_MODE) jsonFail(e.code || "E_SCHEDULE_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
2877
1929
  }
2878
1930
  }
2879
1931
 
2880
1932
  async function sessionCmd() {
2881
1933
  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" });
1934
+ 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
1935
  const home = flag("home");
2884
1936
  let result;
2885
1937
  if (args[1] === "attach") {
@@ -2888,16 +1940,16 @@ async function sessionCmd() {
2888
1940
  return;
2889
1941
  }
2890
1942
  if (args[1] === "inspect") result = inspectInstanceSession(home);
2891
- else if (args[1] === "recompose") result = recomposeInstanceInstructions(home, { dryRun: args.includes("--dry-run") });
1943
+ 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
1944
  else if (args[1] === "start" || args[1] === "restart") {
2893
1945
  const bad = (msg) => { throw Object.assign(new Error(msg), { code: "E_BAD_ARGS" }); };
2894
1946
  const model = flag("model");
2895
1947
  if (model === true) bad("--model needs a model id; omit it to keep the recorded model");
2896
1948
  const launchConfig = flag("launch-config");
2897
1949
  if (launchConfig === true) bad("--launch-config needs a configuration name, or none");
2898
- const runtime = flag("runtime");
2899
- 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 };
1950
+ const harness = harnessFlag();
1951
+ if (harness === true || (harness !== undefined && !LAUNCH_HARNESSES.includes(harness))) bad(`--harness must be one of ${LAUNCH_HARNESSES.join(", ")}`);
1952
+ const opts = { model: model || undefined, launchConfig, harness, yolo: yoloFlag(), env: process.env, ...(await homeLiveTeams(home)) };
2901
1953
  if (args[1] === "restart") {
2902
1954
  const grace = flag("stop-grace");
2903
1955
  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; }
@@ -2920,9 +1972,9 @@ async function sessionCmd() {
2920
1972
  const file = flag("file");
2921
1973
  if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
2922
1974
  result = uploadAttachment({ file, home: home === true ? undefined : home });
2923
- } 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" });
1975
+ } 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] [--harness pi|claude|codex] [--yolo|--no-yolo] [--stop-grace seconds] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
2924
1976
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2925
- } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
1977
+ } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message, e.details); }
2926
1978
  }
2927
1979
 
2928
1980
  async function paneCmd() {
@@ -2935,8 +1987,7 @@ async function paneCmd() {
2935
1987
  * (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
2936
1988
  * `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
2937
1989
  * 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
1990
+ * remotes, confirm membership, resolve `packages:`, write `oats-lock.json`. Nothing is installed, no soul
2940
1991
  * is created, nothing is spawned, no `oats-config.yaml` is written: the member
2941
1992
  * clones and the operator expert are the operator's next steps, printed here. */
2942
1993
  async function onboardCmd() {
@@ -3043,14 +2094,14 @@ async function onboardCmd() {
3043
2094
  const hostIsMember = members.some((m) => m.key === synced.discovery.key);
3044
2095
  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
2096
  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; }
2097
+ if (JSON_MODE) { jsonOk(result); return; }
3047
2098
 
3048
2099
  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
2100
  printSyncReport(ctx, synced);
3050
2101
  console.log(`
3051
2102
  This directory (${shortPath(dir)}) is your deployment — any layout works; it now holds what the kernel needs:
3052
2103
  ├── 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
2104
+ ├── oats-lock.json exact commit + integrity per package
3054
2105
  └── agents/ instance homes, each self-contained
3055
2106
  Member clones live wherever you keep them (here, or anywhere named in oats-local.yaml clones:).
3056
2107
 
@@ -3060,9 +2111,8 @@ Next:
3060
2111
  2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
3061
2112
  names every member, so if any member is private the host must be a private repo that is not
3062
2113
  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;
2114
+ 3. ${setupExpert ? "Spawn the operator expert to guide the rest (souls, teams, provider settings):" : "No soul named oats-operator-expert is listed here —"}
2115
+ ${spawnHint ?? anySoulHint}`);
3066
2116
  }
3067
2117
 
3068
2118
  /** The clone URL of a member row: what the remote observed (from the workspace's members: refs;
@@ -3075,45 +2125,6 @@ function memberUrlOf(discovery, key) {
3075
2125
  return null;
3076
2126
  }
3077
2127
 
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
2128
  // ---------- capability command dispatch ----------
3118
2129
  /**
3119
2130
  * oats <namespace> <command> [args…] — run a command an active capability
@@ -3127,7 +2138,7 @@ function createCmd() {
3127
2138
  * namespace's capability into <deployment>/.oats/modules/<cap>@<commit12>/
3128
2139
  * and run THAT copy with the soul's merged payload (lib/operator-dispatch.mjs;
3129
2140
  * contracts doc, "Post-0.25.0 clarifications");
3130
- * - otherwise the classic config chain.
2141
+ * - otherwise no namespace is active (the help fallthrough answers).
3131
2142
  */
3132
2143
  async function capabilityCommand() {
3133
2144
  // JSON-aware boundary: in --json mode every dispatch failure — inactive or
@@ -3159,64 +2170,79 @@ async function capabilityCommand() {
3159
2170
  throw e;
3160
2171
  }
3161
2172
  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);
2173
+ // The same team/workspace facts a spawn hook receives (lead decision c3-7).
2174
+ const teamCtx = teamEnv(resolvedFromPrepared(hit.prepared, hit.deployment));
2175
+ // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2176
+ // already fetched exactly this commit; otherwise the command gets none (never ambient).
2177
+ const cachedSoul = hit.soul?.commit ? join(hit.deployment, "agents", hit.soul.name, "souls", String(hit.soul.commit).slice(0, 12)) : null;
2178
+ return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
3164
2179
  }
3165
2180
 
3166
2181
  async function dispatch() {
3167
2182
  let activeIds;
3168
2183
  let context = process.cwd();
3169
- let teamCtx;
3170
- const instanceHome = process.env.PI_AGENT_HOME || process.env.OATS_HOME;
2184
+ let teamCtx, homeMeta, homeTeamCtx;
2185
+ // OATS_INSTANCE_HOME is the canonical identity; the older names still count.
2186
+ const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME;
3171
2187
  const metaFile = instanceHome && join(instanceHome, "instance.json");
3172
2188
  // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
3173
2189
  // braces: the ids come from instance.json, which spawn wrote from resolved
3174
2190
  // manifests. Null-prototype because the dispatcher indexes it with the
3175
2191
  // namespace the operator typed on the command line.
3176
2192
  let capSettings = Object.create(null);
3177
- let instanceModules = false;
3178
2193
  let deployment = null;
2194
+ let soulDir;
3179
2195
  try {
3180
2196
  if (metaFile && existsSync(metaFile)) {
3181
2197
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
3182
- instanceModules = !!(meta.modules && typeof meta.modules === "object");
3183
2198
  activeIds = (meta.capabilities || []).map((c) => c.id);
3184
2199
  for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
2200
+ // Workspace-model homes only (lead decision c3 Q2).
2201
+ if (isCapturedHome(meta)) { const e = capturedHomeRefusal(instanceHome, "nothing was dispatched"); bail(e.code, e.message, e.details); }
2202
+ if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
3185
2203
  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;
2204
+ soulDir = instanceSoulDir(instanceHome, meta);
2205
+ // The team/workspace facts the home recorded at spawn, as its hooks got them, with the
2206
+ // recorded eligible teams (OATS_TEAMS_SOURCE=recorded). Only the home's MESSAGING module
2207
+ // gets them live (below): its team verbs (join/leave/teams) must see what the workspace
2208
+ // allows now, and no other command pays a remote read for them.
2209
+ const ws = meta.workspace && typeof meta.workspace === "object" ? meta.workspace : {};
2210
+ const messaging = (meta.capabilities || []).find((c) => c.layer === "messaging")?.id;
2211
+ homeMeta = { meta, messaging };
2212
+ 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 });
2213
+ teamCtx = homeTeamCtx(meta.teams, "recorded");
3189
2214
  } else {
3190
2215
  // Not inside a home: a v2 deployment (oats-local.yaml in reach) resolves
3191
2216
  // through the workspace, exactly as a spawn of --soul would (below).
3192
2217
  try { const { deploymentOf } = await import("../lib/operator-dispatch.mjs"); deployment = deploymentOf(context); }
3193
2218
  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
- }
2219
+ // No home and no deployment in reach: no capability namespace is active.
2220
+ if (!deployment) return NOT_DISPATCHED;
3200
2221
  }
3201
2222
  } catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
3202
2223
  if (deployment) return operatorDispatch();
3203
2224
  // Workspace model: an instance's own materialized modules are the command
3204
2225
  // 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);
2226
+ const mans = Object.values(capabilityManifests(instanceHome)).filter((m) => m.command === cmd && m.commands);
3206
2227
  if (!mans.length) return NOT_DISPATCHED;
3207
2228
  if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
3208
2229
  const m = mans[0];
3209
2230
  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);
2231
+ const trust = capabilityTrust(m);
3211
2232
  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);
2233
+ if (homeMeta && m.capability === homeMeta.messaging) {
2234
+ const { liveTeams } = await import("../lib/instance-resolution.mjs");
2235
+ const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2236
+ teamCtx = homeTeamCtx(live.teams, live.source);
2237
+ }
2238
+ return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir, soulDir);
3213
2239
  }
3214
2240
 
3215
2241
  /** Help / unknown-command / spec validation / exec — shared by every context.
3216
2242
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
3217
2243
  * resolves the directory holding the executable — the operator branch fetches
3218
2244
  * the module tree only when a command is actually going to run). */
3219
- async function runManifestCommand(m, settings, teamCtx, ensureDir) {
2245
+ async function runManifestCommand(m, settings, teamCtx, ensureDir, soulDir) {
3220
2246
  const sub = args[1];
3221
2247
  const cmds = Object.keys(m.commands);
3222
2248
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -3250,8 +2276,11 @@ async function capabilityCommand() {
3250
2276
  try { abs = capabilityExecutablePath(withDir, script); }
3251
2277
  catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
3252
2278
  if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(dir, script)})`);
2279
+ // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2280
+ // invoking process names some other soul (a coordinator's own), never this one.
2281
+ const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
3253
2282
  const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
3254
- ...process.env, OATS_CAPABILITY: m.capability,
2283
+ ...inherited, OATS_CAPABILITY: m.capability,
3255
2284
  // Package-runtime boundary: dispatched commands receive the active
3256
2285
  // capability's EFFECTIVE settings (instance snapshot, resolved context, or
3257
2286
  // the soul's merged payload on operator-level dispatch), same contract as
@@ -3262,7 +2291,10 @@ async function capabilityCommand() {
3262
2291
  // canonical absolute executable of THIS CLI; official consumers execFile
3263
2292
  // it directly and never resolve `oats` from PATH or a shell.
3264
2293
  OATS_CLI_BIN: CLI_BIN,
3265
- OATS_TEAM_NAME: teamCtx?.name || "", OATS_TEAM_ID: teamCtx?.id || "", OATS_TEAM_SCOPE: teamCtx?.scope || "",
2294
+ ...teamEnv(null), ...(teamCtx || {}),
2295
+ // The soul the command acts for (an instance home's recorded soul directory):
2296
+ // homes carry no soul link, so providers read it here.
2297
+ ...(soulDir ? { OATS_SOUL: soulDir } : {}),
3266
2298
  } });
3267
2299
  // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
3268
2300
  if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
@@ -3270,52 +2302,6 @@ async function capabilityCommand() {
3270
2302
  }
3271
2303
  }
3272
2304
 
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
2305
 
3320
2306
  // ---------- update ----------
3321
2307
  function updateCmd() {
@@ -3370,7 +2356,7 @@ function versionCmd() {
3370
2356
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3371
2357
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3372
2358
  // 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"] }));
2359
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["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", "harness"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
3374
2360
  return;
3375
2361
  }
3376
2362
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3508,14 +2494,14 @@ async function serverRouteCmd() {
3508
2494
  // The operations contract addresses an exact member context on the host,
3509
2495
  // so its explicit --dir travels; every other routed command takes its
3510
2496
  // scope from the registration.
3511
- const explicitScopeOk = ["inspect", "operation", "soul", "launch-config"].includes(cmd);
2497
+ const explicitScopeOk = ["inspect", "operation", "launch-config"].includes(cmd);
3512
2498
  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
2499
  if (cmd === "launch-config") {
3514
2500
  const action = args[1];
3515
2501
  const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
3516
2502
  if (!["list", "set", "remove", "preview"].includes(action)) bail("E_BAD_ARGS", "launch-config --server supports list, set, remove and preview");
3517
2503
  const options = { action, name: args[2], context: value("dir"), home: value("home"), instance: value("instance"), soul: value("soul"), agentsRoot: value("agents-root") };
3518
- if (action === "preview") Object.assign(options, { launchConfig: value("launch-config"), runtime: value("runtime"), model: value("model"), yolo: yoloFlag() });
2504
+ if (action === "preview") Object.assign(options, { launchConfig: value("launch-config"), harness: harnessFlag(value), model: value("model"), yolo: yoloFlag() });
3519
2505
  if (action === "set") {
3520
2506
  const file = value("file");
3521
2507
  if (!file) bail("E_BAD_ARGS", "launch-config set needs --file <local JSON file> (or - for stdin)");
@@ -3534,7 +2520,7 @@ async function serverRouteCmd() {
3534
2520
  let out;
3535
2521
  try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message); }
3536
2522
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3537
- if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
2523
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3538
2524
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3539
2525
  console.log(JSON.stringify(out.envelope.result, null, 2));
3540
2526
  return;
@@ -3551,7 +2537,7 @@ async function serverRouteCmd() {
3551
2537
  let routed;
3552
2538
  try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message); }
3553
2539
  if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
3554
- if (JSON_MODE) { console.log(JSON.stringify(routed.envelope, null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
2540
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(routed.envelope), null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
3555
2541
  if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
3556
2542
  const hr = routed.envelope.result;
3557
2543
  console.log(`Harvest on ${id} for ${inst}: ${hr.harvest}${hr.reason ? ` (${hr.reason})` : ""}${hr.instance && hr.harvest === "spawned" ? ` — harvester ${hr.instance}` : ""}`);
@@ -3579,7 +2565,7 @@ async function serverRouteCmd() {
3579
2565
  let out;
3580
2566
  try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message); }
3581
2567
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3582
- if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
2568
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3583
2569
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3584
2570
  console.log(JSON.stringify(out.envelope.result, null, 2));
3585
2571
  return;
@@ -3592,7 +2578,7 @@ async function serverRouteCmd() {
3592
2578
  let out;
3593
2579
  try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message); }
3594
2580
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3595
- if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
2581
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3596
2582
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "inspect failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3597
2583
  const r = out.envelope.result;
3598
2584
  console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
@@ -3600,12 +2586,12 @@ async function serverRouteCmd() {
3600
2586
  }
3601
2587
  if (args[1] === "start" || args[1] === "restart") {
3602
2588
  const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
3603
- const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), runtime: value("runtime"), yolo: yoloFlag() };
2589
+ const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), harness: harnessFlag(value), yolo: yoloFlag() };
3604
2590
  if (flag("stop-grace") !== undefined) bail("E_BAD_ARGS", "--stop-grace is currently supported on the execution host; omit it to use the remote restart's default wait");
3605
2591
  let out;
3606
2592
  try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message); }
3607
2593
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3608
- if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
2594
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3609
2595
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3610
2596
  const r = out.envelope.result;
3611
2597
  console.log(`Started ${r.instance || r.home} on ${id} (${r.backend}${r.model ? `, model ${r.model}` : ""}, ${r.reused === "pane" ? "in its existing pane" : r.reused === "adopted" ? "adopted the pending session" : "new window"})`);
@@ -3635,7 +2621,6 @@ async function serverRouteCmd() {
3635
2621
  // local --task-file is read here and travels as --task text, since the
3636
2622
  // remote cannot read this machine's files.
3637
2623
  const rest = [];
3638
- let routedInput;
3639
2624
  for (let i = 1; i < args.length; i++) {
3640
2625
  const a = args[i];
3641
2626
  if (a === "--server") { i++; continue; }
@@ -3665,27 +2650,14 @@ async function serverRouteCmd() {
3665
2650
  rest.push("--wake-message", readFileSync(f, "utf8"));
3666
2651
  continue;
3667
2652
  }
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
2653
  rest.push(a);
3682
2654
  }
3683
2655
  let routed;
3684
- try { routed = routeCommand(id, cmd, rest, routedInput === undefined ? {} : { input: routedInput }); }
2656
+ try { routed = routeCommand(id, cmd, rest); }
3685
2657
  catch (e) { bail(e.code || "E_SSH", e.message); }
3686
2658
  const { envelope, stderr } = routed;
3687
2659
  if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
3688
- if (JSON_MODE) { console.log(JSON.stringify(envelope, null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
2660
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
3689
2661
  if (!envelope.ok && !(cmd === "retire" && envelope.result)) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3690
2662
  const r = envelope.result;
3691
2663
  const target = r.target || {};
@@ -3706,7 +2678,8 @@ async function serverRouteCmd() {
3706
2678
  console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3707
2679
  for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
3708
2680
  console.log(`Work that was not committed has been preserved on ${target.sshHost}: ${(recovery.classes || []).join(", ")}`);
3709
- console.log(` ${recovery.path}`);
2681
+ console.log(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
2682
+ for (const line of preservedOutputLines(recovery)) console.log(line);
3710
2683
  }
3711
2684
  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
2685
  } else {
@@ -3735,70 +2708,44 @@ async function serverRouteCmd() {
3735
2708
  // blame` pointing at the commit that last changed each command.
3736
2709
  const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3737
2710
  /** 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" };
2711
+ 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
2712
  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
- }
2713
+ // The captured/portable path was removed in 0.26 (lead decisions on (e), D2/D3):
2714
+ // its selectors and an inherited captured context are refused, never quietly
2715
+ // resolved against the current context instead. `version` answers regardless:
2716
+ // host protocol negotiation describes this executable.
2717
+ {
2718
+ const end = args.indexOf("--"), head = end < 0 ? args : args.slice(0, end);
2719
+ const selector = head.find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
2720
+ const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
2721
+ 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] });
2722
+ const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
2723
+ 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 });
2724
+ if (cmd === "inspect" && head.some((a) => a === "--request" || a.startsWith("--request="))) {
2725
+ 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";
2726
+ if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: "inspect --request", replacement: "oats onboard / oats sync; oats spawn --preview" });
2727
+ die(message);
2728
+ }
2729
+ }
2730
+ if (cmd === "onboard") {
3771
2731
  if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
3772
2732
  await onboardCmd();
3773
2733
  }
3774
2734
  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
2735
  // `--help`/`-h` anywhere after a kernel command prints that command's usage
3788
2736
  // and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
3789
2737
  // using a command, and `install --help` once ran the bare restore while
3790
2738
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
3791
2739
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
3792
2740
  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();
2741
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"].includes(cmd)) await serverRouteCmd();
3794
2742
  else if (cmd === "server") serverCmd();
3795
- else if (cmd === "inspect") inspectCmd();
3796
- else if (cmd === "operation") operationCmd();
3797
- else if (cmd === "soul") await soulCmd();
2743
+ else if (cmd === "inspect") await inspectCmd();
2744
+ else if (cmd === "operation") await operationCmd();
3798
2745
  else if (cmd === "launch-config") await launchConfigCmd();
3799
2746
  else if (cmd === "doctor") {
3800
2747
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
3801
- args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
2748
+ await (args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir));
3802
2749
  }
3803
2750
  else if (cmd === "update") {
3804
2751
  // `oats update <package>` left with the installed tier (packages are pinned in
@@ -3807,8 +2754,7 @@ else if (cmd === "update") {
3807
2754
  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
2755
  updateCmd();
3809
2756
  }
3810
- else if (cmd === "type") typeCmd();
3811
- else if (cmd === "readiness") readinessCmd();
2757
+ else if (cmd === "readiness") await readinessCmd();
3812
2758
  else if (cmd === "instance") instanceCmd();
3813
2759
  else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
3814
2760
  else if (cmd === "sync") await syncCmd();
@@ -3824,7 +2770,6 @@ else if (cmd === "session") await sessionCmd();
3824
2770
  else if (cmd === "schedule") scheduleCmd();
3825
2771
  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
2772
  else if (cmd === "retire") retireCmd();
3827
- else if (cmd === "create") createCmd();
3828
2773
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
3829
2774
  else if (cmd === "experimental") await experimentalCmd();
3830
2775
  // `!HELP_WORDS.has(cmd)`: usage NEVER depends on deployment state. `help` is a
@@ -3874,7 +2819,6 @@ Usage:
3874
2819
  oats version [--json] kernel version; --json emits the
3875
2820
  Desktop CLI API v1 probe payload
3876
2821
  oats status [--json] agents, souls, running instances
3877
- oats status --team [--json] whole-team roster across the team scope's repos
3878
2822
  oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3879
2823
  --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3880
2824
  [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
@@ -3898,24 +2842,19 @@ Usage:
3898
2842
  oats session start --server <id> start a stopped remote instance in its existing home
3899
2843
  --instance <name> | --home <abs> over its saved route; the server must advertise
3900
2844
  [--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
2845
+ oats inspect|operation --server <id> the same commands on a registered server over its
3902
2846
  ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
3903
2847
  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)
2848
+ the server must advertise operations (oats 0.22.16 or later)
3906
2849
  oats session upload --server <id> copy a local file into a remote instance's private
3907
2850
  --instance <name> | --home <abs> attachments over its saved route (bytes stream on
3908
2851
  --file <path> [--json] ssh stdin; sha256 verified); the server must
3909
2852
  advertise session-upload (oats 0.22.13 or later)
3910
2853
  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
2854
+ [--json] and agents/, then runs the oats sync path (lock v3)
2855
+ and prints the
3913
2856
  next steps (clone members you work IN, spawn
3914
2857
  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
2858
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3920
2859
  oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3921
2860
  enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
@@ -3931,7 +2870,7 @@ Usage:
3931
2870
  <file> [--json] upload's remote half)
3932
2871
  oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3933
2872
  [--model m] [--launch-config n|none] (recorded recipe as is; a selection re-resolves it
3934
- [--runtime r] [--yolo|--no-yolo] against the scope; a named configuration is a unit)
2873
+ [--harness r] [--yolo|--no-yolo] against the scope; a named configuration is a unit)
3935
2874
  oats session restart --home <abs-home> stop the running harness (SIGTERM, bounded wait,
3936
2875
  [same flags] [--stop-grace <s>] never escalated) and start it again in place under
3937
2876
  the same lock; a stop that is not observed is
@@ -3940,45 +2879,41 @@ Usage:
3940
2879
  spawn hooks); --model replaces the recorded model
3941
2880
  for this and later starts; a live harness is refused
3942
2881
  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;
2882
+ [--purpose <slug>] [--repo <r>] = scaffold only); the agent is a workspace
2883
+ [--parent <instance>] soul or a capability-defined agent
3946
2884
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3947
2885
  [--relative-to <instance>] new instance to an existing one; --parent X
3948
2886
  [--relative-root <agents-root>] disambiguates same-named team anchors
3949
2887
  [--work worktree|checkout|attached|workspace|directory] = sugar for --relative-to X --relation
3950
- [--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
2888
+ [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
2889
+ [--no-launch] [--json]
2890
+ with team: declared, unknown souls
3953
2891
  resolve across the team scope's repos
3954
2892
  directory: owned home/work, config context may
3955
2893
  be non-Git; rejects --work-dir and --branch
2894
+ [--name <slug>] the exact instance name (no <agent>- prefix;
2895
+ not with --purpose); must be a slug, not a
2896
+ soul name, and unused in the deployment
3956
2897
  oats retire <instance> [--force] retire an instance (window, hooks,
3957
2898
  [--self] [--delete-branch] worktree, home); --self = retire the
3958
2899
  [--keep-dir] [--json] CALLING instance: the window dies, then
3959
2900
  a detached external retirement runs
3960
2901
  oats inspect [--dir <scope>] [--soul <name> one authoritative JSON answer for a GUI: souls
3961
- [--agents-root <abs>]] [--home <abs>] (runtime defaults, editability, instructions),
2902
+ [--agents-root <abs>]] [--home <abs>] (harness defaults, editability, instructions),
3962
2903
  [--json] installed capabilities with health, effective
3963
2904
  layer bindings and activation, declared
3964
2905
  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
2906
+ running home's recorded modules and their drift
2907
+ from the deployment
2908
+ oats operation run <layer>:<name> run an operation the soul's core capability for that
3968
2909
  (--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
2910
+ [--arg k=v ...] [--json] inspect ...): resolved from the home's recorded
2911
+ modules or the deployment's soul, trust checked, the provider's
3971
2912
  own command run in the home or scope, envelope
3972
2913
  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
2914
  oats launch-config list [--dir <scope> named launch configurations effective at a scope,
3980
2915
  | --home <abs> | --soul <name>] a home's recorded context or a soul's own scope:
3981
- [--agents-root <abs>] [--json] runtime, executable, args, env (values redacted,
2916
+ [--agents-root <abs>] [--json] harness, executable, args, env (values redacted,
3982
2917
  references shown), model, yolo; the closest
3983
2918
  declaring scope provides the whole entry
3984
2919
  oats launch-config set <name> --file <j> declare or replace one at this scope from a JSON
@@ -3986,32 +2921,32 @@ Usage:
3986
2921
  --keep-env copies the effective definition's env)
3987
2922
  oats launch-config remove <name> remove this scope's declaration; an ancestor's,
3988
2923
  [--dir <scope>] [--json] if any, becomes effective again
3989
- oats launch-config preview what a start would run: resolved runtime, model,
2924
+ oats launch-config preview what a start would run: resolved harness, model,
3990
2925
  (--home <abs> | --soul <name>) yolo, executable, argv, environment (redacted),
3991
2926
  [--launch-config <name>|none] command and preflight; read-only, nothing
3992
- [--runtime r] [--model m] started; a named configuration is a unit, so
3993
- [--yolo | --no-yolo] --json a disagreeing --runtime is refused
2927
+ [--harness r] [--model m] started; a named configuration is a unit, so
2928
+ [--yolo | --no-yolo] --json a disagreeing --harness is refused
3994
2929
  oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
3995
2930
  --soul shows final composed AGENTS.md
3996
2931
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
3997
2932
  optionally run the update; then run oats doctor
3998
2933
  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
2934
+ oats-local.yaml over its Git remote, confirm every
4000
2935
  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)
2936
+ packages: to exact commits + integrity, write
2937
+ oats-lock.json (v3) and report the diff. No approval
2938
+ step: declaring a package in packages: is the trust
2939
+ decision (--approve is E_BAD_ARGS)
4008
2940
  oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
4009
2941
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
4010
2942
  print the line to add (the file travels through Git)
4011
2943
  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
2944
+ cannot-read / backlink-elsewhere), locked packages
2945
+ oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
2946
+ private one is listed as repo-owned: usable only by
2947
+ its own repo's souls) + the locked packages
2948
+ oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
2949
+ (souls have no private mode), with origin
4015
2950
  (member <key> @ <commit> | package <id> v<ver>) and team
4016
2951
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
4017
2952
  read-only Git observation of the instance's work
@@ -4020,10 +2955,6 @@ Usage:
4020
2955
  oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json]
4021
2956
  bounded diff of one observed file; refuses when
4022
2957
  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
2958
  oats instance events <instance> [--limit <n>] [--since <iso>] [--json]
4028
2959
  typed lifecycle events (spawned, launched, stopped,
4029
2960
  restarted, retired, worktree-retained…) written by
@@ -4034,14 +2965,15 @@ Usage:
4034
2965
  oats instance stop <instance> --apply --plan-revision <rev> --idempotency-key <key>
4035
2966
  quiesce (SIGTERM, bounded, never escalated),
4036
2967
  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
2968
+ oats readiness (--soul <n> | --home <abs>) [--policy] [--json]
2969
+ readinessApi 2 for a soul or an instance home:
2970
+ installed | configured | member | providers, each
4042
2971
  pass|fail|unknown|not-applicable with items and
4043
- remedies; signature status per artifact; enforced
4044
- child-spawn / worktree policy with origins
2972
+ remedies; providers relays each bound provider's
2973
+ own check ({status, problems, warnings});
2974
+ --policy: enforced child-spawn / worktree policy
2975
+ with origins (captured homes refuse: the
2976
+ captured/portable path was removed in 0.26)
4045
2977
  oats retire <instance> --plan [--json] what Remove would touch, with retention defaults
4046
2978
  oats retire <instance> [--plan-revision <rev> --idempotency-key <key>] [--discard-worktree] [--delete-branch]
4047
2979
  with a plan revision: refuses E_PLAN_STALE (fresh plan
@@ -4050,8 +2982,6 @@ Usage:
4050
2982
  <workspace>/.agents/worktrees/<repo>/<branch>)
4051
2983
  unless discarded; --delete-branch deletes the
4052
2984
  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
2985
  oats root print this package's install root
4056
2986
  (adapters resolve the kernel from it)
4057
2987
 
@@ -4069,43 +2999,6 @@ The turn record (core — every conversation captured, searchable, replicated):
4069
2999
  design, repo checkout only; see
4070
3000
  packages/experimental/README.md
4071
3001
 
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
3002
  oats <namespace> <command> [args…] run an operational command only when its
4110
3003
  capability is active (e.g. oats okf harvest)
4111
3004