@awebai/oats 0.23.2 → 0.24.1

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 (172) hide show
  1. package/README.md +224 -391
  2. package/bin/oats-pi-sdk-host.mjs +17 -0
  3. package/bin/oats.mjs +470 -51
  4. package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
  5. package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
  6. package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
  7. package/capabilities/oats-okf/lib/captured-worker.mjs +109 -0
  8. package/capabilities/oats-okf/lib/config.mjs +2 -1
  9. package/capabilities/oats-okf/lib/inspection.mjs +16 -1
  10. package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
  11. package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
  12. package/capabilities/oats-okf/lib/io.mjs +1 -1
  13. package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
  14. package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
  15. package/capabilities/oats-okf/lib/sources.mjs +123 -3
  16. package/capabilities/oats-okf/lib/stores.mjs +104 -25
  17. package/capabilities/oats-okf/lib/worker.mjs +69 -10
  18. package/capabilities/oats-okf/oats.json +35 -7
  19. package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
  20. package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
  21. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
  22. package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
  23. package/docs/artifact-approvals.schema.json +7 -0
  24. package/docs/capabilities.md +4 -0
  25. package/docs/capability-manifest.schema.json +37 -66
  26. package/docs/captured-invocation-context.schema.json +7 -0
  27. package/docs/captured-resolution.schema.json +7 -0
  28. package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
  29. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
  30. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
  31. package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
  32. package/docs/design/2026-09-15-captured-dispatch.md +127 -0
  33. package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
  34. package/docs/design/2026-09-15-package-preparation.md +100 -0
  35. package/docs/design/2026-09-15-portable-data-contract.md +121 -0
  36. package/docs/design/2026-09-15-portable-declarations.md +189 -0
  37. package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
  38. package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
  39. package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
  40. package/docs/design/2026-09-15-source-observation.md +119 -0
  41. package/docs/design/2026-09-16-captured-admission.md +77 -0
  42. package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
  43. package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
  44. package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
  45. package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
  46. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
  47. package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
  48. package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
  49. package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
  50. package/docs/design/2026-09-16-portable-onboarding.md +177 -0
  51. package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
  52. package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
  53. package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
  54. package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
  55. package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
  56. package/docs/design/2026-09-17-captured-native-start.md +58 -0
  57. package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
  58. package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
  59. package/docs/design/2026-09-17-public-captured-start.md +108 -0
  60. package/docs/design/2026-09-17-public-prepare-request.md +90 -0
  61. package/docs/design/2026-09-18-captured-pi-host.md +205 -0
  62. package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
  63. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
  64. package/docs/design/2026-09-20-redesign-program-board.md +70 -0
  65. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +287 -0
  66. package/docs/design/2026-09-20-workspace-onboarding-public.md +124 -0
  67. package/docs/design/README.md +42 -0
  68. package/docs/desktop-cli-api.md +5 -2
  69. package/docs/execution-capsule.schema.json +108 -0
  70. package/docs/execution-targets.md +20 -7
  71. package/docs/first-team.md +1 -1
  72. package/docs/knowledge-theory.md +353 -111
  73. package/docs/knowledge.md +10 -1
  74. package/docs/layers.md +89 -354
  75. package/docs/oats-lock-v3.schema.json +7 -0
  76. package/docs/oats-member.schema.json +38 -0
  77. package/docs/oats-workspace.schema.json +68 -0
  78. package/docs/official-marketplace.md +79 -0
  79. package/docs/packages.md +4 -0
  80. package/docs/portable.schema.json +2512 -0
  81. package/docs/provider-check-input.schema.json +7 -0
  82. package/docs/release-notes/v0.24.0.md +104 -0
  83. package/docs/release-notes/v0.24.1.md +17 -0
  84. package/docs/schedules.md +126 -14
  85. package/docs/soul.schema.json +82 -0
  86. package/docs/souls-and-instances.md +20 -7
  87. package/docs/workspace-adoption.md +285 -0
  88. package/docs/workspaces.md +154 -0
  89. package/injects/oats-portable.md +16 -0
  90. package/injects/portable-instance-boundary.md +39 -0
  91. package/injects/portable-work-directory.md +29 -0
  92. package/lib/artifact-approvals.mjs +120 -0
  93. package/lib/artifact-tree.mjs +141 -0
  94. package/lib/capability-artifacts.mjs +179 -0
  95. package/lib/capability-execution.mjs +15 -0
  96. package/lib/capability-inputs.mjs +39 -0
  97. package/lib/capability-provenance.mjs +231 -0
  98. package/lib/captured-action-shape.mjs +21 -0
  99. package/lib/captured-admission-shape.mjs +20 -0
  100. package/lib/captured-binding-file.mjs +36 -0
  101. package/lib/captured-dispatch.mjs +66 -0
  102. package/lib/captured-instance-index.mjs +277 -0
  103. package/lib/captured-invocation-context.mjs +130 -0
  104. package/lib/captured-launch-request.mjs +46 -0
  105. package/lib/captured-operation-process.mjs +15 -0
  106. package/lib/captured-pi-custody.mjs +29 -0
  107. package/lib/captured-pi-host.mjs +167 -0
  108. package/lib/captured-pi-outcome.mjs +172 -0
  109. package/lib/captured-resolutions.mjs +275 -0
  110. package/lib/captured-scaffold.mjs +87 -0
  111. package/lib/captured-selector.mjs +28 -0
  112. package/lib/captured-session-backend.mjs +52 -0
  113. package/lib/captured-source-receipt-file.mjs +72 -0
  114. package/lib/config-data.mjs +104 -0
  115. package/lib/core.mjs +961 -566
  116. package/lib/errors.mjs +7 -0
  117. package/lib/helper-injection-policy.mjs +98 -0
  118. package/lib/herdr.mjs +18 -7
  119. package/lib/instruction-composition.mjs +31 -0
  120. package/lib/legacy-lock-codec.mjs +106 -0
  121. package/lib/manifest-settings.mjs +84 -0
  122. package/lib/package-closure.mjs +48 -0
  123. package/lib/package-materialization.mjs +83 -0
  124. package/lib/pi-sdk-host.mjs +229 -0
  125. package/lib/portable-artifacts.mjs +115 -0
  126. package/lib/portable-choices.mjs +82 -0
  127. package/lib/portable-composition.mjs +136 -0
  128. package/lib/portable-digest.mjs +105 -0
  129. package/lib/portable-files.mjs +26 -0
  130. package/lib/portable-identity.mjs +40 -0
  131. package/lib/portable-lock.mjs +117 -0
  132. package/lib/portable-migration-artifacts.mjs +135 -0
  133. package/lib/portable-migration-evidence.mjs +305 -0
  134. package/lib/portable-migration-store.mjs +199 -0
  135. package/lib/portable-migration.mjs +104 -0
  136. package/lib/portable-onboarding-acceptance.mjs +66 -0
  137. package/lib/portable-onboarding-request.mjs +49 -0
  138. package/lib/portable-onboarding.mjs +249 -0
  139. package/lib/portable-package-preparation.mjs +188 -0
  140. package/lib/portable-policy.mjs +44 -0
  141. package/lib/portable-shape.mjs +35 -0
  142. package/lib/portable-soul.mjs +38 -0
  143. package/lib/portable-state.mjs +80 -0
  144. package/lib/portable-values.mjs +181 -0
  145. package/lib/prepare-composition.mjs +151 -0
  146. package/lib/prepared-bindings.mjs +78 -0
  147. package/lib/prepared-resources.mjs +127 -0
  148. package/lib/provider-binding-broker.mjs +59 -0
  149. package/lib/provider-binding-wire.mjs +110 -0
  150. package/lib/provider-binding.mjs +22 -0
  151. package/lib/repository-observation.mjs +226 -0
  152. package/lib/resolution-shape.mjs +393 -0
  153. package/lib/schedule-capsule.mjs +206 -0
  154. package/lib/schedule.mjs +259 -38
  155. package/lib/servers.mjs +15 -0
  156. package/lib/soul-constraints.mjs +40 -0
  157. package/lib/source-projection.mjs +84 -0
  158. package/lib/source-spec.mjs +189 -0
  159. package/lib/workspace-definition.mjs +126 -0
  160. package/lib/workspace-discovery.mjs +146 -0
  161. package/package-catalog.json +2 -1
  162. package/package.json +3 -2
  163. package/packages/record/lib/capture-cc.mjs +14 -6
  164. package/packages/record/lib/formats.mjs +14 -3
  165. package/packages/record/lib/native-history.mjs +277 -7
  166. package/packages/record/lib/session-snapshot.mjs +25 -5
  167. package/packages/record/lib/sessions-for-home.mjs +30 -13
  168. package/skills/oats/SKILL.md +12 -7
  169. package/skills/oats-config/SKILL.md +11 -9
  170. package/skills/oats-packages/SKILL.md +12 -8
  171. package/skills/oats-portable/SKILL.md +115 -0
  172. package/skills/oats-portable-artifacts/SKILL.md +63 -0
package/lib/core.mjs CHANGED
@@ -35,11 +35,66 @@ import { accessSync, constants as fsConstants } from "node:fs";
35
35
  import { homedir, tmpdir } from "node:os";
36
36
  import { createHash, randomUUID } from "node:crypto";
37
37
  import { fileURLToPath } from "node:url";
38
- import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
38
+ import { initializeNativeHistory, prepareNativeStart, nativeHistoryPath } from "../packages/record/lib/native-history.mjs";
39
+ import { PI_SDK_HOST, isPiSdkHost, validatePiHostRecipe, piHostArgv, resolvePiSdkEntry } from "./pi-sdk-host.mjs";
40
+ import { capturedPiSessionDirectory, requireCapturedPiRecordSupport, inspectCapturedPiRoot, prepareCapturedPiStart } from "./captured-pi-custody.mjs";
39
41
  import { attachSessionTarget } from "./session-viewer.mjs";
40
42
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
41
43
  import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
42
44
 
45
+ import { oatsError } from "./errors.mjs";
46
+ const CAPTURED_NATIVE_START = Symbol("captured-native-start");
47
+ import { renderInstructionText } from "./instruction-composition.mjs";
48
+ import { loadCapturedAction } from "./captured-dispatch.mjs";
49
+ import { materializeCapturedDirectoryScaffold, validateCapturedDirectoryTarget } from "./captured-scaffold.mjs";
50
+ import { buildCapturedInvocationContext, withCapturedInvocationContextFile } from "./captured-invocation-context.mjs";
51
+ import { readCapturedInstanceIndex, readCapturedInstanceMetadata, readCapturedInstanceAuthority, assertCapturedInstanceCustody,
52
+ recordCapturedCustodyFailure, markCapturedNativeScaffold, failCapturedNativeIntent, observeCapturedNativeTarget, setCapturedInstanceStatus, admitCapturedInstanceAction, beginCapturedIntent, settleCapturedIntent } from "./captured-instance-index.mjs";
53
+ import { validateExecutionBinding } from "./schedule-capsule.mjs";
54
+ import { validateIntentRef } from "./captured-admission-shape.mjs";
55
+ export { readCapturedInstanceIndex, readCapturedInstanceMetadata, beginCapturedIntent, settleCapturedIntent };
56
+ export { buildCapturedInvocationContext, withCapturedInvocationContextFile };
57
+ import { approveAvailableCapability as approveAvailableArtifact } from "./artifact-approvals.mjs";
58
+ import { prepareComposition } from "./prepare-composition.mjs";
59
+ import { completePreparedResources } from "./prepared-resources.mjs";
60
+ import { validateCapturedLaunchRequest } from "./captured-launch-request.mjs";
61
+ import { validateCapabilityInputDeclarations } from "./capability-inputs.mjs";
62
+ import { validateCapturedSessionBackend, validateCapturedSessionTarget, assertCapturedSessionPlacement } from "./captured-session-backend.mjs";
63
+ import { createRepositoryTransaction } from "./repository-observation.mjs";
64
+ import { inspectPortableOnboarding as inspectOnboarding, describePortableOnboarding } from "./portable-onboarding.mjs";
65
+ import { readLock3 } from "./portable-lock.mjs";
66
+ import { portableScope, portableStateDirectory } from "./portable-state.mjs";
67
+ import { objectAt } from "./portable-shape.mjs";
68
+ import { validateBindingInterface } from "./provider-binding.mjs";
69
+ import { invokeProviderBinding } from "./provider-binding-broker.mjs";
70
+ import { withCapturedBindingFile } from "./captured-binding-file.mjs";
71
+ import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
72
+ export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
73
+ import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
74
+ import { createCapabilityMaterializer } from "./package-materialization.mjs";
75
+ import { resolvePackageClosure } from "./package-closure.mjs";
76
+ import { canonicalJson, parseStrictJson, decodeUtf8 } from "./portable-values.mjs";
77
+ import { treeIntegrity } from "./portable-digest.mjs";
78
+ import { readPortableBytes } from "./portable-files.mjs";
79
+ import { capabilityArtifactIntegrity, copyTreeSafe, removeOwnedStaging } from "./artifact-tree.mjs";
80
+ import {
81
+ CAPABILITIES_DIRNAME, INSTALLED_SUBDIR,
82
+ isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
83
+ normalizePackagePath, parseLockSource, PACKAGE_ID_RE,
84
+ validateLockEntry, validateCapabilityLockEntry, verifyCapabilityInstallation,
85
+ } from "./capability-provenance.mjs";
86
+ import { decodeLegacyLockBytes, legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
87
+ // Keep the existing public core surface; internal data helpers are not re-exported.
88
+ export { oatsError } from "./errors.mjs";
89
+ export { legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
90
+ export { capabilityArtifactIntegrity, copyTreeSafe } from "./artifact-tree.mjs";
91
+ export {
92
+ CAPABILITIES_DIRNAME, INSTALLED_SUBDIR, CAPABILITY_ID_RE,
93
+ isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
94
+ normalizePackagePath, validateLockEntry, validateCapabilityLockEntry,
95
+ verifyCapabilityInstallation,
96
+ } from "./capability-provenance.mjs";
97
+
43
98
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
44
99
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
45
100
  * must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
@@ -711,16 +766,20 @@ function bindingObject(value) {
711
766
  function hookDeclaration(value) {
712
767
  if (typeof value === "string") return { command: value, required: false };
713
768
  if (value && typeof value === "object" && typeof value.command === "string") {
714
- return { command: value.command, required: value.required === true };
769
+ return { command: value.command, required: value.required === true, ...(Object.hasOwn(value, "inputs") ? { inputs: value.inputs } : {}) };
715
770
  }
716
771
  return undefined;
717
772
  }
773
+ function manifestHookDeclarations(manifest) {
774
+ return Object.fromEntries(Object.entries(manifest?.hooks || {}).flatMap(([event, value]) => {
775
+ const declaration = hookDeclaration(value);
776
+ return APPROVED_HOOKS.has(event) && declaration ? [[event, declaration.command]] : [];
777
+ }));
778
+ }
718
779
  function manifestHookCommands(manifest) {
719
780
  const out = {};
720
- for (const [ev, value] of Object.entries(manifest?.hooks || {})) {
721
- const decl = hookDeclaration(value);
722
- if (!APPROVED_HOOKS.has(ev) || !decl) continue;
723
- const [script, ...args] = decl.command.split(/\s+/);
781
+ for (const [ev, command] of Object.entries(manifestHookDeclarations(manifest))) {
782
+ const [script, ...args] = command.split(/\s+/);
724
783
  const abs = manifestPath(manifest, script);
725
784
  if (abs) out[ev] = ["node", shq(abs), ...args].join(" ");
726
785
  }
@@ -849,11 +908,7 @@ export function resolveCapabilities(contextDir, soulName) {
849
908
  if (![...enabledValues][0]) continue;
850
909
  // A declared value set is enforced for an ACTIVE capability: a misspelling
851
910
  // must not switch a conditional requirement off by matching nothing.
852
- for (const [key, decl] of Object.entries(declaredSettings)) {
853
- if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
854
- throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
855
- }
856
- }
911
+ assertCapabilitySettingValues(manifest, settings);
857
912
  const compatibility = capabilityCompatibility(manifest);
858
913
  if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
859
914
  const trust = capabilityTrust(manifest, contextDir);
@@ -988,33 +1043,10 @@ const OATS_HOME_DIR = process.env.OATS_HOME_DIR || join(homedir(), ".oats");
988
1043
  /** Legacy pre-v0.8 laptop acquisition root — kept only so doctor can warn about it. */
989
1044
  export const LEGACY_HOME_CAPABILITIES_DIR = join(OATS_HOME_DIR, "capabilities");
990
1045
  export const OATS_LOCK_FILE = "oats-lock.json";
991
- /** Scope-relative capability store subtrees. */
992
- export const CAPABILITIES_DIRNAME = join(".agents", "capabilities");
993
- export const INSTALLED_SUBDIR = "installed";
994
1046
  export const OWNED_SUBDIR = "owned";
995
1047
  export const installedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, INSTALLED_SUBDIR);
996
1048
  export const ownedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, OWNED_SUBDIR);
997
1049
 
998
- /** THE identity grammar for a MATERIALIZED (revised-v2) capability.
999
- *
1000
- * A materialized capability id is not merely a label: it becomes a DIRECTORY
1001
- * NAME directly under `installed/`, so anything that can steer a filesystem
1002
- * join must be impossible before the join happens. The grammar is deliberately
1003
- * the package-id grammar — namespaced dots are fine, and `/`, `\`, `..`,
1004
- * absolute forms, `@`, and percent-encoded spellings are all outside it.
1005
- *
1006
- * The LEGACY v1 / owned / `from: path:` grammar (loadManifestAt) stays looser
1007
- * on purpose: those artifacts are named by `basename()` of their source, never
1008
- * by the declared id, so the id never reaches a path there. Tightening it would
1009
- * strand already-published standalone capabilities. */
1010
- export const CAPABILITY_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
1011
- export const isMaterializedCapabilityId = (id) => typeof id === "string" && CAPABILITY_ID_RE.test(id);
1012
- /** Why a capability id was refused, in one sentence, for every caller's own
1013
- * typed error (the lock parser raises invalid-lock, manifest validation raises
1014
- * invalid-package-manifest — the code each consumer already branches on). */
1015
- export const capabilityIdViolation = (id) =>
1016
- `${JSON.stringify(id)} is not a valid capability identity — expected ${CAPABILITY_ID_RE.source} (a namespaced id such as "oats.okf"; path separators, "..", absolute paths, "@" and encoded forms are refused because the id names a directory under ${INSTALLED_SUBDIR}/)`;
1017
-
1018
1050
  const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
1019
1051
  const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
1020
1052
  const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
@@ -1108,17 +1140,18 @@ export function stripInternalAnnotations(parsed) {
1108
1140
  return out;
1109
1141
  }
1110
1142
 
1111
- function loadManifestAt(idir, origin) {
1143
+ function loadManifestAt(idir, origin, { strict = false } = {}) {
1112
1144
  const mf = join(idir, "oats.json");
1113
1145
  if (!existsSync(mf)) return undefined;
1114
1146
  let raw;
1115
- try { raw = JSON.parse(readFileSync(mf, "utf8")); }
1116
- catch (e) { throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
1147
+ try { raw = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
1148
+ catch (e) { if (strict) throw e; throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
1117
1149
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`capability manifest ${mf} must be a JSON object (got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw})`);
1118
1150
  // BEFORE any validation or annotation: the artifact declares capability DATA,
1119
1151
  // never kernel annotations about itself.
1120
1152
  const m = stripInternalAnnotations(raw);
1121
1153
  const id = validateCapabilityManifest(m, mf);
1154
+ validateCapabilityInputDeclarations(m);
1122
1155
  if (!m.version || !m.description) throw new Error(`capability ${id} manifest needs version and description`);
1123
1156
  const targetFields = ["global", "groups", "souls", "targets"].filter((key) => Object.prototype.hasOwnProperty.call(m, key));
1124
1157
  if (targetFields.length) throw new Error(`capability ${id} manifest cannot declare config-owned targets: ${targetFields.join(", ")}`);
@@ -1126,6 +1159,7 @@ function loadManifestAt(idir, origin) {
1126
1159
  if (m.command && !/^[a-z0-9][a-z0-9-]*$/.test(m.command)) throw new Error(`capability ${id} has invalid command namespace "${m.command}"`);
1127
1160
  for (const [hook, value] of Object.entries(m.hooks || {})) {
1128
1161
  if (!APPROVED_HOOKS.has(hook)) throw new Error(`capability ${id} declares unsupported hook "${hook}"`);
1162
+ if (value && typeof value === "object") objectAt(value, ["command", "required", "inputs"], ["command"]);
1129
1163
  if (!hookDeclaration(value)) throw new Error(`capability ${id} hook "${hook}" must be a command string or { command, required }`);
1130
1164
  if (value && typeof value === "object" && value.required !== undefined && typeof value.required !== "boolean") {
1131
1165
  throw new Error(`capability ${id} hook "${hook}": "required" must be a boolean`);
@@ -1136,9 +1170,347 @@ function loadManifestAt(idir, origin) {
1136
1170
  }
1137
1171
  if (m.agents !== undefined && (!Array.isArray(m.agents) || m.agents.some((a) => typeof a !== "string"))) throw new Error(`capability ${id} "agents" must be an array of package-relative soul directories`);
1138
1172
  validateManifestOperations(m, id);
1173
+ validateBindingInterface(m);
1139
1174
  return { ...m, _dir: idir, _origin: origin };
1140
1175
  }
1141
1176
 
1177
+ function assertCapabilitySettingValues(manifest, settings) {
1178
+ for (const [key, decl] of Object.entries(manifest.settings || {})) {
1179
+ if (decl && typeof decl === "object" && Array.isArray(decl.values) && Object.hasOwn(settings, key) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
1180
+ throw new Error(`capability setting ${manifest.capability}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ function loadRetainedManifest(root) {
1186
+ const manifest = loadManifestAt(root, "captured", { strict: true });
1187
+ if (!manifest) throw oatsError("resolution-incomplete", "retained capability manifest is absent");
1188
+ assertCapabilitySelfContained(root, manifest);
1189
+ const compatible = capabilityCompatibility(manifest);
1190
+ if (!compatible.compatible) throw oatsError("capability-incompatible", `captured capability requires OATS ${compatible.range}`);
1191
+ return manifest;
1192
+ }
1193
+
1194
+ export function approveAvailableCapability(deployment, artifactSet, capability, origin) {
1195
+ return approveAvailableArtifact(deployment, artifactSet, capability, origin, loadRetainedManifest);
1196
+ }
1197
+
1198
+ /** Public read-only source/workspace inspection through the existing facade.
1199
+ * Owns only transient repository scratch, never deployment state, provisioning,
1200
+ * provider code, approvals or a serialized mutation witness. */
1201
+ export function inspectPortableOnboarding(input, { repositoryOptions = {} } = {}) {
1202
+ canonicalJson(input);
1203
+ const directory = mkdtempSync(join(realpathSync(tmpdir()), "oats-source-inspection-")), owned = lstatSync(directory);
1204
+ let repositories, primary;
1205
+ try {
1206
+ repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
1207
+ return describePortableOnboarding(inspectOnboarding(input, { repositories }));
1208
+ } catch (error) { primary = error; throw error; }
1209
+ finally {
1210
+ try {
1211
+ repositories?.close();
1212
+ const current = lstatSync(directory);
1213
+ if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "inspection scratch ownership changed");
1214
+ removeOwnedStaging(directory);
1215
+ } catch (cleanup) {
1216
+ if (!primary) throw cleanup;
1217
+ const error = new AggregateError([primary, cleanup], "inspection and owned scratch cleanup failed", { cause: primary });
1218
+ error.code = primary.code; error.stagingPath = directory; throw error;
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ /** Explicit new-work preparation, with one frozen repository transaction.
1224
+ * This first adapter captures command/curriculum profiles; provider-owned
1225
+ * binding or distinct helper policy gaps return needs-configuration, never
1226
+ * a fabricated complete record. No lifecycle side effects run here. */
1227
+ export function prepareCapturedComposition(input, { repositoryOptions = {} } = {}) {
1228
+ canonicalJson(input);
1229
+ objectAt(input, ["deployment", "source", "origin", "workspace", "member", "operator", "mode", "allowLocalPaths", "standaloneContextKey", "launch", "helperLaunches"], ["deployment", "source"]);
1230
+ if (input.launch !== undefined) validateCapturedLaunchRequest(input.launch, validateLaunchConfig);
1231
+ if (input.helperLaunches !== undefined) {
1232
+ objectAt(input.helperLaunches, null, []);
1233
+ for (const request of Object.values(input.helperLaunches)) validateCapturedLaunchRequest(request, validateLaunchConfig);
1234
+ }
1235
+ if (input.mode !== undefined && !WORK_MODES.includes(input.mode)) throw oatsError("invalid-declaration", "invalid preparation work mode");
1236
+ const deployment = portableScope(input.deployment);
1237
+ const previous = readLock3(deployment); // Old state refuses before scratch/fetch.
1238
+ const directory = mkdtempSync(join(portableStateDirectory(deployment, true), ".prepare-")), owned = lstatSync(directory);
1239
+ const origin = input.origin ?? { kind: "operator", document: { kind: "operator", id: "oats-prepare" }, pointer: "/source" };
1240
+ let repositories, primary, catalog;
1241
+ try {
1242
+ repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
1243
+ return prepareComposition({ ...input, deployment, origin, directory }, { repositories, previous, kernel: {
1244
+ loadPackageManifestAt, capabilityCompatibility, assertPlatformInvariantLocks, materializeCapability,
1245
+ manifest: loadRetainedManifest,
1246
+ binding: runCapturedProviderBinding,
1247
+ catalog(id, selector) {
1248
+ catalog ??= officialPackageCatalog();
1249
+ if (!Object.hasOwn(catalog, id)) throw oatsError("invalid-source", "unknown package dependency catalog identity");
1250
+ return { ...catalog[id], ...(selector === undefined ? {} : { ref: selector }) };
1251
+ },
1252
+ complete: data => completePreparedResources(data, { root: PKG_ROOT, workModes: WORK_MODES,
1253
+ settings: assertCapabilitySettingValues, skills: skillEntriesIn, hooks: manifestHookDeclarations,
1254
+ validateLaunchConfig, runtimeRequirements: applicableRequirements }),
1255
+ } });
1256
+ } catch (error) { primary = error; throw error; }
1257
+ finally {
1258
+ try {
1259
+ repositories?.close();
1260
+ const current = lstatSync(directory);
1261
+ if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "preparation scratch ownership changed");
1262
+ removeOwnedStaging(directory);
1263
+ } catch (cleanup) {
1264
+ if (!primary) throw cleanup;
1265
+ const error = new AggregateError([primary, cleanup], "preparation and owned scratch cleanup failed", { cause: primary });
1266
+ error.code = primary.code; error.stagingPath = directory; throw error;
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ /** Load an exact record for one action, using the existing COMPLETE kernel
1272
+ * manifest codecs. No scoped config, mutable install or marketplace fallback.
1273
+ * Launch/lifecycle adoption and provider qualification are separate integration
1274
+ * steps; unsupported actions refuse rather than switching to legacy dispatch. */
1275
+ function capturedCapabilityCodecs() {
1276
+ return {
1277
+ manifest: loadRetainedManifest,
1278
+ settings: assertCapabilitySettingValues,
1279
+ host: ({ manifest }) => (manifest.requires || []).filter((requirement) => requirement.command && !which(requirement.command)),
1280
+ executable(manifest, relativePath) {
1281
+ const file = capabilityExecutablePath(manifest, relativePath);
1282
+ if (!file || !statSync(file).isFile()) throw oatsError("resource-not-found", "captured executable is not a regular file");
1283
+ return realpathSync(file);
1284
+ },
1285
+ };
1286
+ }
1287
+
1288
+ /** Exact prospective/captured binding phases; no partial record or ambient provider. */
1289
+ export function runCapturedProviderBinding(options) {
1290
+ return invokeProviderBinding(options, capturedCapabilityCodecs());
1291
+ }
1292
+
1293
+ function capturedDispatchCodecs(options, { admitting = false } = {}) {
1294
+ return {
1295
+ ...capturedCapabilityCodecs(),
1296
+ launch(recipe) {
1297
+ assertLaunchRecipe(recipe, "captured resolution");
1298
+ validateLaunchConfig("captured", { runtime: recipe.runtime, executable: recipe.executable, args: recipe.args, env: recipe.env,
1299
+ ...(recipe.model != null ? { model: recipe.model } : {}), ...(recipe.yolo !== undefined ? { yolo: recipe.yolo } : {}) }, "captured resolution");
1300
+ },
1301
+ operations: manifestOperations,
1302
+ hooks: manifestHookDeclarations,
1303
+ invocation(base, action, capability) {
1304
+ return buildCapturedInvocationContext({ loaded: { ...base, capability }, action,
1305
+ instance: options.invocationTarget ?? null, intent: options.intent ?? null,
1306
+ ...(Object.hasOwn(options, "priorReceipt") ? { priorReceipt: options.priorReceipt } : {}) });
1307
+ },
1308
+ bindings(bindings, capability, record, invocation) {
1309
+ // Admission is static authorization to ATTEMPT, not readiness or effects.
1310
+ // The normal loader still checks readiness with the saved intent before execution.
1311
+ if (admitting) return;
1312
+ for (const [slot, binding] of bindings) {
1313
+ if (capability.manifest.layer !== slot) throw oatsError("invalid-resolution", "captured binding slot differs from the manifest owner");
1314
+ const result = runCapturedProviderBinding({ deployment: options.deployment, artifacts: record.artifacts,
1315
+ capability: capability.id, phase: "check", settings: capability.settings,
1316
+ input: { binding, context: record.context, action: options.action, ...(invocation ? { invocation } : {}) } });
1317
+ if (result.status !== "ready") throw oatsError(result.problems[0]?.code ?? "provider-not-qualified", "captured provider is not ready for this action");
1318
+ }
1319
+ },
1320
+ };
1321
+ }
1322
+
1323
+ export function loadCapturedDispatch(options) {
1324
+ return loadCapturedAction(options, capturedDispatchCodecs(options));
1325
+ }
1326
+
1327
+ /** Admit against exact retained action/approval and current owned incarnation.
1328
+ * No provider phase runs here; readiness/execution consume the persisted intent. */
1329
+ export function admitCapturedAction({ deployment, resolution, home, action, input = {}, retryExecutionId, ...rest }) {
1330
+ canonicalJson(rest); objectAt(rest, ["priorReceipt"], []);
1331
+ const metadata = readCapturedInstanceMetadata(home);
1332
+ const options = { deployment, resolution, action,
1333
+ invocationTarget: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent },
1334
+ ...(Object.hasOwn(rest, "priorReceipt") ? { priorReceipt: rest.priorReceipt } : {}) };
1335
+ const loaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
1336
+ if (!loaded.capability) throw oatsError("unsupported-action", "inspection/composition does not admit mutation");
1337
+ return admitCapturedInstanceAction({ deployment, home, executionBinding: loaded.invocation.executionBinding,
1338
+ capability: loaded.capability.id, action, input, ...(retryExecutionId !== undefined ? { retryExecutionId } : {}) });
1339
+ }
1340
+
1341
+ /** Static callable API availability, not instance/provider/host readiness.
1342
+ * This descriptor never probes a backend or admits a native request. */
1343
+ export function capturedNativeSessionAvailability() {
1344
+ return { schemaVersion: 1, api: { contract: "oats.captured-session", version: 2, available: true, backends: ["tmux", "herdr"] }, readiness: { status: "not-checked" } };
1345
+ }
1346
+
1347
+ /** Resolve only a source record's explicit helper edge. This is static retained
1348
+ * authority inspection, never helper-name discovery, preparation or launch. */
1349
+ export function resolveCapturedHelper(input) {
1350
+ canonicalJson(input);
1351
+ objectAt(input, ["executionBinding", "helper", "name"], ["executionBinding", "helper"]);
1352
+ validateExecutionBinding(input.executionBinding);
1353
+ if (typeof input.helper !== "string" || !input.helper || input.helper.includes("\0")) throw oatsError("invalid-declaration", "helper must be an exact captured helper-map key");
1354
+ if (Object.hasOwn(input, "name") && (typeof input.name !== "string" || !input.name)) throw oatsError("invalid-declaration", "expected helper name must be non-empty text");
1355
+ const deployment = portableScope(input.executionBinding.deployment);
1356
+ const source = loadCapturedDispatch({ deployment, resolution: input.executionBinding.resolution, action: { kind: "inspect" } });
1357
+ if (!Object.hasOwn(source.record.helpers, input.helper)) throw oatsError("helper-not-selected", "source resolution does not select that helper key");
1358
+ const resolution = source.record.helpers[input.helper];
1359
+ const helper = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
1360
+ if (helper.record.subject.kind !== "helper") throw oatsError("invalid-resolution", "captured helper edge does not name a dedicated helper");
1361
+ if (input.name !== undefined && input.name !== helper.record.subject.name) throw oatsError("invalid-resolution", "captured helper name differs from requested name");
1362
+ const responsibleHuman = helper.record.messagingChoice.enabled ? helper.record.messagingChoice.privateKey.human : null;
1363
+ const sourceHuman = source.record.messagingChoice.enabled ? source.record.messagingChoice.privateKey.human : null;
1364
+ if (canonicalJson(source.record.context) !== canonicalJson(helper.record.context) || canonicalJson(sourceHuman) !== canonicalJson(responsibleHuman)) {
1365
+ throw oatsError("needs-configuration", "distinct helper context/human requires an explicit captured helper-request policy; no implicit inheritance was used");
1366
+ }
1367
+ if (!helper.record.dispatch.composition) throw oatsError("resolution-incomplete", "selected helper has no captured curriculum");
1368
+ return {
1369
+ schemaVersion: 1,
1370
+ sourceExecutionBinding: { schemaVersion: 1, deployment, resolution: source.resolution },
1371
+ executionBinding: { schemaVersion: 1, deployment, resolution },
1372
+ helper: { key: input.helper, name: helper.record.subject.name, subject: helper.record.subject },
1373
+ context: helper.record.context, responsibleHuman, workMode: helper.record.dispatch.composition.mode,
1374
+ launchSelection: helper.record.dispatch.launch === null ? null : {
1375
+ runtime: helper.record.dispatch.launch.runtime, model: helper.record.dispatch.launch.model,
1376
+ },
1377
+ launch: capturedNativeSessionAvailability(),
1378
+ };
1379
+ }
1380
+
1381
+ /** Bounded scaffold-only adoption of one exact captured composition. Placement
1382
+ * is explicit and launch/hooks remain pending; unsupported work modes refuse. */
1383
+ export function scaffoldCapturedInstance({ deployment, resolution, home, instance }) {
1384
+ const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
1385
+ const blocked = loaded.approvals.filter((entry) => !["approved", "not-required"].includes(entry.status));
1386
+ if (blocked.length) throw oatsError("approval-required", "captured scaffold needs approval for every selected executable artifact", blocked);
1387
+ validateCapturedDirectoryTarget(home, instance);
1388
+ if (loaded.record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured scaffold currently requires retained directory work mode");
1389
+ if (existsSync(home)) throw oatsError("E_INSTANCE_EXISTS", `instance home already exists: ${home}`);
1390
+ // Native evidence is initialized only for this newly created incarnation.
1391
+ // Existing sidecars are retained obligations, never silent history backfill.
1392
+ const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
1393
+ const parents = [dirname(history), dirname(dirname(baseline)), dirname(baseline)];
1394
+ const parentGuard = (create = false) => {
1395
+ for (const path of parents) {
1396
+ if (create && !existsSync(path)) mkdirSync(path, { mode: 0o700 });
1397
+ if (existsSync(path) && !lstatSync(path).isDirectory()) throw oatsError("integrity-drift", "native custody parent is not an owned directory");
1398
+ }
1399
+ };
1400
+ parentGuard();
1401
+ if (existsSync(history) || existsSync(baseline)) throw oatsError("migration-required", "native custody already exists at this home address; preserve it and select a fresh home");
1402
+ const result = materializeCapturedDirectoryScaffold({ home, instance, loaded });
1403
+ try {
1404
+ const original = readCapturedInstanceAuthority(deployment, home).row;
1405
+ const assertRoots = () => { assertCapturedInstanceCustody(original); parentGuard(); };
1406
+ assertRoots(); parentGuard(true); initializeNativeHistory(home);
1407
+ writeRetirementBaseline(home, join(home, "work"), "directory", {}, [], { launched: false },
1408
+ { exclusive: true, incarnationId: original.incarnationId, executionBinding: original.executionBinding });
1409
+ assertRoots();
1410
+ const metadata = readCapturedInstanceMetadata(home);
1411
+ const proof = { schemaVersion: 1, directories: { historyRoot: directoryIdentity(dirname(history)), history: directoryIdentity(history),
1412
+ retirementRoot: directoryIdentity(dirname(dirname(baseline))), baselines: directoryIdentity(dirname(baseline)) } };
1413
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...metadata, captured: { ...metadata.captured, nativeScaffold: proof } }), { assertRoots });
1414
+ markCapturedNativeScaffold(deployment, home, proof);
1415
+ return result;
1416
+ } catch (error) { error.home = home; error.cleanupRequired = true; throw error; }
1417
+ }
1418
+
1419
+ /** Run retained spawn hooks for an exact scaffold. Required-hook failure retains
1420
+ * the home and its receipts for explicit compensation; launch stays pending. */
1421
+ export function activateCapturedScaffold(options) {
1422
+ let { deployment, resolution, home, extraEnv = {} } = options;
1423
+ home = resolve(home);
1424
+ const file = join(home, "instance.json");
1425
+ let metadata = readCapturedInstanceMetadata(home);
1426
+ const original = readCapturedInstanceAuthority(deployment, home).row;
1427
+ const retry = Object.hasOwn(options, "retryIntents"), retryIntents = retry ? options.retryIntents : {};
1428
+ canonicalJson(retryIntents); objectAt(retryIntents, null, []);
1429
+ const savedIntents = {};
1430
+ for (const intent of original.intents.filter(entry => entry.action.kind === "hook" && entry.action.name === "spawn")) {
1431
+ if (Object.hasOwn(savedIntents, intent.capability)) throw oatsError("invalid-resolution", "multiple spawn requests need explicit reconciliation, not inferred retry selection");
1432
+ savedIntents[intent.capability] = { schemaVersion: 1, executionId: intent.executionId, incarnationId: original.incarnationId, attempt: intent.attempt };
1433
+ }
1434
+ for (const [id, intent] of Object.entries(metadata.captured.hookIntents || {})) {
1435
+ if (savedIntents[id]?.executionId !== intent.executionId || intent.incarnationId !== original.incarnationId) throw oatsError("invalid-resolution", "saved spawn reference differs from indexed authority");
1436
+ }
1437
+ if (retry) {
1438
+ const expected = Object.fromEntries(Object.entries(savedIntents).map(([id, intent]) => [id, intent.executionId]));
1439
+ if (canonicalJson(expected) !== canonicalJson(retryIntents)) throw oatsError("invalid-resolution", "spawn retry must name every indexed hook intent exactly; no partial implicit new requests");
1440
+ } else if (Object.keys(savedIntents).length) throw oatsError("invalid-resolution", "existing spawn obligations require an explicit retry");
1441
+ const priorStatus = retry ? metadata.captured.lifecycle : "scaffolded-hooks-pending";
1442
+ if (retry && !["spawn-failed-cleanup-required", "spawned-cleanup-required"].includes(priorStatus)) throw oatsError("invalid-resolution", "captured scaffold is not awaiting activation retry");
1443
+ const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
1444
+ const composed = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
1445
+ const subject = inspected.record.subject, expectedAgent = subject.kind === "persistent" ? subject.soul.alias : subject.name;
1446
+ const expectedHuman = inspected.record.messagingChoice.enabled ? inspected.record.messagingChoice.privateKey.human : null;
1447
+ let sameDeployment = false;
1448
+ try { sameDeployment = portableScope(metadata.executionBinding?.deployment) === portableScope(deployment); } catch { /* refused below */ }
1449
+ if (metadata.home !== home || metadata.instance !== basename(home) || metadata.agent !== expectedAgent
1450
+ || metadata.captured?.lifecycle !== priorStatus || metadata.executionBinding?.resolution?.id !== resolution.id
1451
+ || !sameDeployment || canonicalJson(metadata.responsibleHuman) !== canonicalJson(expectedHuman)) {
1452
+ throw oatsError("invalid-resolution", "captured scaffold metadata differs from the requested resolution or home");
1453
+ }
1454
+ const assertRoots = () => {
1455
+ const row = assertCapturedInstanceCustody(original);
1456
+ if (row.status !== "spawn-hooks-running") throw oatsError("selection-changed", "activation no longer owns the indexed lifecycle state");
1457
+ return row;
1458
+ };
1459
+ const reportFailure = (error, observed = {}) => {
1460
+ const facts = { meta: observed.meta || {}, intents: observed.intents || {} };
1461
+ let report, reportingFailure;
1462
+ try { report = recordCapturedCustodyFailure(original, { ...facts, code: error.code || "E_CAPTURED_PUBLICATION", message: error.message }); }
1463
+ catch (failure) { reportingFailure = { code: failure.code || "E_CAPTURED_CUSTODY", message: String(failure.message).slice(0, 200) }; }
1464
+ // Independent ledger first; if that too is held, propagate the observed
1465
+ // receipt through the existing caller error channel, not the foreign home.
1466
+ error.home = home;
1467
+ error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: original.executionBinding,
1468
+ ...facts, ...(report ? { report } : {}), ...(reportingFailure ? { reportingFailure } : {}) };
1469
+ throw error;
1470
+ };
1471
+ const publish = (next, status, observed = {}) => {
1472
+ try {
1473
+ atomicWriteFileSync(file, canonicalJson(next), { assertRoots });
1474
+ assertRoots();
1475
+ setCapturedInstanceStatus(deployment, home, status, { expectedStatus: "spawn-hooks-running" });
1476
+ } catch (error) { reportFailure(error, observed); }
1477
+ };
1478
+ setCapturedInstanceStatus(deployment, home, "spawn-hooks-running", { expectedStatus: priorStatus });
1479
+ let hooks;
1480
+ try {
1481
+ hooks = runCapturedLifecycleHooks("spawn", { deployment, resolution, home, instance: metadata.instance, agentName: expectedAgent, retryIntents, assertRoots,
1482
+ extraEnv: { ...extraEnv, OATS_WORK: "directory", OATS_KIND: subject.kind } });
1483
+ } catch (error) {
1484
+ const failure = { capability: "oats.kernel", event: "spawn", message: String(error.message || error).slice(0, 200), required: true, unconfirmed: true };
1485
+ metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...error.capturedHooks?.meta }, captured: { ...metadata.captured,
1486
+ lifecycle: "spawn-failed-cleanup-required", hookOrder: error.capturedHooks?.order || [], hookFailures: [failure],
1487
+ hookIntents: { ...savedIntents, ...error.capturedHooks?.intents } } };
1488
+ publish(metadata, "spawn-failed-cleanup-required", error.capturedHooks);
1489
+ error.home = home; throw error;
1490
+ }
1491
+ const required = hooks.failures.filter((failure) => failure.required);
1492
+ let unsettled;
1493
+ try {
1494
+ const row = readCapturedInstanceIndex(deployment).instances.find(row => row.incarnationId === original.incarnationId);
1495
+ if (!row) throw oatsError("selection-changed", "captured activation is no longer indexed");
1496
+ unsettled = row.intents.some(intent => intent.state !== "completed");
1497
+ } catch (error) { reportFailure(error, hooks); }
1498
+ // Optional functionality can fail without a required-hook error, but custody
1499
+ // remains explicitly nonterminal and the saved-map retry route stays usable.
1500
+ const status = required.length ? "spawn-failed-cleanup-required" : unsettled ? "spawned-cleanup-required" : "spawned-launch-pending";
1501
+ hooks.intents = { ...savedIntents, ...hooks.intents };
1502
+ metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...hooks.meta }, captured: { ...metadata.captured,
1503
+ lifecycle: status, hookOrder: hooks.order, hookFailures: hooks.failures, hookIntents: hooks.intents } };
1504
+ publish(metadata, status, hooks);
1505
+ if (required.length) {
1506
+ const error = oatsError("E_REQUIRED_HOOK_FAILED", "captured spawn hook failed; home and receipts are retained for explicit cleanup", required);
1507
+ error.home = home; throw error;
1508
+ }
1509
+ return { home, instance: metadata.instance, agent: expectedAgent, incarnationId: metadata.incarnationId, executionBinding: metadata.executionBinding,
1510
+ responsibleHuman: expectedHuman, launched: false, hooks, launchPending: true, functionalReady: true,
1511
+ hooksPending: unsettled, cleanupRequired: unsettled };
1512
+ }
1513
+
1142
1514
  /** `operations`: what a capability offers a GUI or scheduler by name, each
1143
1515
  * delegating to one of its own `commands`. kind "action" runs something;
1144
1516
  * kind "view" answers { documents: [...] } for presentation (a knowledge
@@ -1251,40 +1623,6 @@ export function capabilityManifest(name, startDir) {
1251
1623
  return capabilityManifests(startDir)[name];
1252
1624
  }
1253
1625
 
1254
- /** Recursively copy a tree the way `cpSync(..., { recursive: true })` would —
1255
- * except catchably.
1256
- *
1257
- * Node 22's recursive `cpSync` performs its recursion in native code, and on
1258
- * macOS an unreadable directory inside the tree surfaces as an uncaught libc++
1259
- * `filesystem_error` that TERMINATES THE PROCESS. No JS `catch` or `finally`
1260
- * runs, so a transaction using it can never clean up staging or roll back the
1261
- * store, the lock and the ignore file. Every package-, capability- and
1262
- * user-shaped tree in the engine therefore goes through this hand-walk instead,
1263
- * where an EACCES is an ordinary throwable error.
1264
- *
1265
- * Semantics chosen to be safe rather than maximally faithful:
1266
- * - deterministic traversal (sorted entries), so two copies of one tree hash
1267
- * identically;
1268
- * - symlinks are recreated VERBATIM — never followed, never rewritten — because
1269
- * the bytes about to be hashed must be the bytes the author wrote;
1270
- * - FIFOs, sockets and device nodes are rejected fail-closed: they are not
1271
- * distributable content, and copying them has no defined meaning here;
1272
- * - directory modes are applied AFTER their children, so a read-only source
1273
- * directory cannot block writing its own contents. */
1274
- export function copyTreeSafe(src, dest) {
1275
- const st = lstatSync(src);
1276
- if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
1277
- if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
1278
- if (!st.isDirectory()) {
1279
- throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
1280
- }
1281
- mkdirSync(dest, { recursive: true });
1282
- for (const e of readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
1283
- copyTreeSafe(join(src, e.name), join(dest, e.name));
1284
- }
1285
- chmodSync(dest, st.mode & 0o7777);
1286
- }
1287
-
1288
1626
  /** Remove source-control metadata from the ROOT of a managed artifact.
1289
1627
  *
1290
1628
  * It must not be installed, and it must not become an integrity exclusion:
@@ -1588,14 +1926,6 @@ export function capabilityCompatibility(manifest, version = OATS_VERSION) {
1588
1926
  }
1589
1927
 
1590
1928
  // ---------- distribution packages (docs/design/package-engine-contract.md) ----------
1591
- /** Kernel error with a stable machine-readable code (contract §4) and optional provenance. */
1592
- export function oatsError(code, message, provenance) {
1593
- const e = new Error(message);
1594
- e.code = code;
1595
- if (provenance) e.provenance = provenance;
1596
- return e;
1597
- }
1598
-
1599
1929
  /** Where one materialized capability artifact lives at a scope.
1600
1930
  *
1601
1931
  * This is the LAST line of defence, and it is a positive proof rather than a
@@ -1614,10 +1944,6 @@ export const installedCapabilityDir = (levelDir, capabilityId) => {
1614
1944
  }
1615
1945
  return dir;
1616
1946
  };
1617
- /** Generated provenance file inside every materialized artifact. It is INSIDE
1618
- * the hashed tree, so tampering with it is integrity drift; the lock stays
1619
- * authoritative. */
1620
- export const CAPABILITY_INSTALLATION_FILE = ".oats-installation.json";
1621
1947
  /** Prefix of a transaction staging directory. It lives inside the (gitignored)
1622
1948
  * installed store so the commit phase is a same-filesystem rename, and it is
1623
1949
  * dot-prefixed so discovery skips it. */
@@ -1628,37 +1954,6 @@ const STAGING_PREFIX = ".staging-";
1628
1954
  * use site: every resolver reads the configured path and falls back here. */
1629
1955
  export const DEFAULT_PACKAGE_PATH = "oats-package";
1630
1956
 
1631
- /** Normalize a configured package path to its canonical form, or throw.
1632
- *
1633
- * Canonical form is a POSIX-relative path with no redundant or trailing
1634
- * separators; every spelling of the repository root ("", ".", "./", "./.")
1635
- * normalizes to the single canonical "." so a root selection round-trips
1636
- * identically through spec → lock → JSON → doctor/list/update (contract §4).
1637
- *
1638
- * Fail-closed: absolute paths, Windows drive paths, host-ambient "~" spellings,
1639
- * backslash separators (ambiguous — a backslash is a legal POSIX filename
1640
- * character, so accepting it as a separator would make containment checks
1641
- * disagree with the filesystem) and NUL are rejected as invalid-source; ".."
1642
- * traversal is path-escape. Returns undefined ONLY for an absent value, so the
1643
- * caller can apply the source-appropriate default. */
1644
- export function normalizePackagePath(raw, { where = "package path", code = "invalid-source" } = {}) {
1645
- // ABSENT means absent. A present `null` (JSON's way of spelling a malformed
1646
- // value) is a violation, not a fall-through to the caller's default — a
1647
- // catalog entry that says `"path": null` must fail, not silently install
1648
- // DEFAULT_PACKAGE_PATH.
1649
- if (raw === undefined) return undefined;
1650
- if (typeof raw !== "string") throw oatsError(code, `${where} must be a string (got ${Array.isArray(raw) ? "array" : raw === null ? "null" : typeof raw})`);
1651
- const s = raw.trim();
1652
- if (s.includes("\0")) throw oatsError(code, `${where} contains a NUL byte`);
1653
- if (s.startsWith("~")) throw oatsError(code, `${where} "${s}" is a host-ambient path — package paths are repository-relative`);
1654
- if (s.includes("\\")) throw oatsError(code, `${where} "${s}" uses backslashes — package paths are POSIX-relative (use "/")`);
1655
- if (/^[A-Za-z]:[/\\]/.test(s)) throw oatsError(code, `${where} "${s}" is an absolute drive path — package paths are repository-relative`);
1656
- if (isAbsolute(s)) throw oatsError(code, `${where} "${s}" is absolute — package paths are repository-relative`);
1657
- const segments = s.split("/").filter((seg) => seg !== "" && seg !== ".");
1658
- if (segments.includes("..")) throw oatsError("path-escape", `${where} "${s}" escapes the source root with ".."`);
1659
- return segments.length ? segments.join("/") : ".";
1660
- }
1661
-
1662
1957
  /** Split an optional `#<package-path>` fragment off a source spec. A source may
1663
1958
  * carry at most one fragment; the fragment is removed BEFORE `@ref` parsing so
1664
1959
  * a path can never be mistaken for part of a ref. */
@@ -1768,51 +2063,6 @@ export function inspectGitSourceRoot(spec) {
1768
2063
  } catch (e) { rmSync(tmp, { recursive: true, force: true }); throw e; }
1769
2064
  }
1770
2065
 
1771
- /** Parse a lock entry's `source` against the EXACT normalized grammar the
1772
- * writer produces. Strict on purpose: `updatePackage` turns this back into a
1773
- * source spec, so a payload that merely "starts with catalog:" but is not a
1774
- * valid catalog id gets RECLASSIFIED downstream — `catalog:../evil` would be
1775
- * re-parsed as a host-relative local path and acquired from the operator's
1776
- * filesystem. A lock also never carries a `#<path>` fragment: the selected
1777
- * root is the entry's own `path` field, and a fragment here would produce a
1778
- * double-fragment spec on update. */
1779
- function parseLockSource(src) {
1780
- const s = String(src || "");
1781
- const bad = (why) => oatsError("invalid-source", `unknown lock source "${src}" — ${why}`);
1782
- if (s.includes("#")) throw bad(`lock sources carry no "#<path>" fragment; the selected package root is the entry's "path" field`);
1783
- if (s.startsWith("path:")) {
1784
- const p = s.slice(5);
1785
- if (!p) throw bad("empty path source");
1786
- if (!isAbsolute(p)) throw bad("path source must be an absolute directory (the writer always resolves it)");
1787
- return { kind: "path", path: p, normalized: s };
1788
- }
1789
- if (s.startsWith("catalog:")) {
1790
- const body = s.slice(8);
1791
- // Split at the FIRST "@", mirroring the public parser's regex: the catalog
1792
- // id grammar cannot contain "@", so everything after the first one is the
1793
- // selector. Splitting at the LAST "@" misreads a legitimate ref spelling
1794
- // such as `oats.okf@release@candidate` — which the writer does produce —
1795
- // as the id `oats.okf@release`.
1796
- const at = body.indexOf("@");
1797
- const id = at > 0 ? body.slice(0, at) : body;
1798
- const selector = at > 0 ? body.slice(at + 1) : undefined;
1799
- if (!PACKAGE_ID_RE.test(id)) throw bad(`"${id}" is not a valid official catalog id`);
1800
- if (at > 0 && !selector) throw bad("empty catalog selector");
1801
- return { kind: "catalog", id, selector, normalized: s };
1802
- }
1803
- if (s.startsWith("git:")) {
1804
- const body = s.slice(4);
1805
- const at = body.lastIndexOf("@") > body.lastIndexOf("/") ? body.lastIndexOf("@") : -1;
1806
- const url = at > 0 ? body.slice(0, at) : body;
1807
- const ref = at > 0 ? body.slice(at + 1) : undefined;
1808
- if (!url) throw bad("empty git url");
1809
- if (at > 0 && !ref) throw bad("empty git ref");
1810
- if (!/^(https?:\/\/|file:\/\/|git@|ssh:\/\/|git:\/\/)/.test(url)) throw bad(`"${url}" is not an http(s)/ssh/file/git URL`);
1811
- return { kind: "git", url, ref, normalized: s };
1812
- }
1813
- throw oatsError("invalid-source", `unknown lock source "${src}"`);
1814
- }
1815
-
1816
2066
  /** Official package catalog: identity + discovery ONLY — resolving through it
1817
2067
  * never advances a lock and never grants executable trust (contract §1).
1818
2068
  * Workstream 3 seeds the kernel-bundled catalog; OATS_PACKAGE_CATALOG points
@@ -1914,27 +2164,6 @@ export function packageIntegrity(dir) {
1914
2164
  return `sha256-${hash.digest("hex")}`;
1915
2165
  }
1916
2166
 
1917
- /** Stable integrity of a MATERIALIZED capability artifact: every byte under
1918
- * `.agents/capabilities/installed/<id>/`, with NO exclusions — capability source,
1919
- * the materialized runtime closure (node_modules), and the generated
1920
- * `.oats-installation.json` provenance file all count. This is the only digest
1921
- * executable trust binds to, which is why a separate dependency digest does not
1922
- * exist at capability level: the closure is inside the artifact, so tampering
1923
- * with a dependency is ordinary artifact drift. */
1924
- export function capabilityArtifactIntegrity(dir) {
1925
- const hash = createHash("sha256");
1926
- const walk = (d) => {
1927
- for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
1928
- const p = join(d, e.name);
1929
- if (e.isDirectory()) walk(p);
1930
- else if (e.isFile()) { hash.update(relative(dir, p)); hash.update("\0file\0"); hash.update(readFileSync(p)); hash.update("\0"); }
1931
- else if (e.isSymbolicLink()) { hash.update(relative(dir, p)); hash.update("\0symlink\0"); hash.update(readlinkSync(p)); hash.update("\0"); }
1932
- }
1933
- };
1934
- walk(dir);
1935
- return `sha256-${hash.digest("hex")}`;
1936
- }
1937
-
1938
2167
  const PACKAGE_MANIFEST_KEYS = new Set(["package", "version", "description", "compatibility", "capabilities", "configTemplates", "configs", "dependencies"]);
1939
2168
  /** Canonical home of a package's config templates. Manifest paths are
1940
2169
  * repository-relative and always spelled with "/", on every platform. */
@@ -1959,12 +2188,12 @@ export function isCanonicalTemplatePath(p) {
1959
2188
  * whichever spelling the manifest used
1960
2189
  * _capabilities [{ id, rel, dir, manifest }]
1961
2190
  */
1962
- export function loadPackageManifestAt(pdir) {
2191
+ export function loadPackageManifestAt(pdir, { strict = false } = {}) {
1963
2192
  const mf = join(pdir, "oats-package.json");
1964
2193
  if (!existsSync(mf)) throw oatsError("invalid-package-manifest", `${pdir} has no oats-package.json distribution manifest`);
1965
2194
  let m;
1966
- try { m = JSON.parse(readFileSync(mf, "utf8")); }
1967
- catch (e) { throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
2195
+ try { m = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
2196
+ catch (e) { if (strict) throw e; throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
1968
2197
  // Hostile-input shapes: JSON null/scalar/array roots are valid JSON but not manifests.
1969
2198
  if (!m || typeof m !== "object" || Array.isArray(m)) throw oatsError("invalid-package-manifest", `${mf} must be a JSON object (got ${m === null ? "null" : Array.isArray(m) ? "array" : typeof m})`);
1970
2199
  if (typeof m.package !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(m.package)) throw oatsError("invalid-package-manifest", `${mf} needs a valid string "package" identity (lowercase [a-z0-9._-])`);
@@ -2015,7 +2244,7 @@ export function loadPackageManifestAt(pdir) {
2015
2244
  for (const rel of m.capabilities) {
2016
2245
  const dir = inside(rel, "capability");
2017
2246
  if (!existsSync(join(dir, "oats.json"))) throw oatsError("invalid-package-manifest", `package ${m.package} capability path ${rel} has no oats.json (not a capability)`);
2018
- const cm = loadManifestAt(dir, `package:${m.package}`);
2247
+ const cm = loadManifestAt(dir, `package:${m.package}`, { strict });
2019
2248
  // A PACKAGE-exported id will be materialized as a directory name under
2020
2249
  // installed/, so it must satisfy the materialized grammar — stricter than
2021
2250
  // the legacy standalone-capability rule loadManifestAt applies, which still
@@ -2129,21 +2358,7 @@ export function assertCapabilitySelfContained(capDir, manifest) {
2129
2358
  }
2130
2359
  }
2131
2360
 
2132
- const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
2133
2361
  export const LOCKFILE_VERSION = 2;
2134
- /** Package rows lock the TRANSPORT unit only: no capability list (the capability
2135
- * rows' `package` back-reference is the single provider truth) and no trust
2136
- * (trust binds to materialized capability artifacts). */
2137
- const LOCK_PACKAGE_KEYS = new Set(["source", "path", "version", "commit", "integrity", "dependencies"]);
2138
- /** Capability rows lock the MATERIALIZED entity. */
2139
- const LOCK_CAPABILITY_KEYS = new Set(["version", "package", "path", "integrity", "trusted"]);
2140
- /** Own-property PRESENCE of any of these on a package row is forbidden
2141
- * transitional evidence (contract §4.1) — never truthiness and never array
2142
- * length, so an empty `capabilities: []` or a dependency-free old row still
2143
- * classifies. Package-row `path`/`dependencies` are NEVER tells: the current
2144
- * shape retains both. */
2145
- const TRANSITIONAL_ROW_FIELDS = ["capabilities", "trustedCapabilities", "depsIntegrity"];
2146
-
2147
2362
  /** A null-prototype copy of a raw parsed JSON map. Raw objects return inherited
2148
2363
  * `constructor`/`toString`/`valueOf` for `map[id]` even with no own entry, so
2149
2364
  * every ID-keyed map in the engine goes through this (or `Object.hasOwn`) before
@@ -2154,93 +2369,15 @@ function nullProtoMap(raw) {
2154
2369
  return out;
2155
2370
  }
2156
2371
 
2157
- /** ONE strict lock parser for v1 and the capability-materialization v2: reads +
2158
- * validates root shape, lockfile version, map shapes and keys, entry shapes
2159
- * (full semantic pass incl. the dependency graph and the capability→package
2160
- * back-references), and the v1 capability map. EVERY violation is a typed
2161
- * invalid-lock with provenance, raised with NO side effects. Old locks are read
2162
- * AS THEY ARE — never normalized, repaired or rewritten (the single exception is
2163
- * the state-free empty transitional document, §4.1). Returns
2164
- * { version, packages, capabilities, legacyCapabilities } (null-prototype and
2165
- * validated) or null when the file does not exist. */
2372
+ /** Public bytes seam for evidence-grade migration. Ordinary consumers use the
2373
+ * file wrapper below; both share the one acyclic legacy decoder. */
2374
+ export function parseLockBytesStrict(bytes, { file = "<oats-lock.json>", limits } = {}) {
2375
+ return decodeLegacyLockBytes(bytes, { file, limits, retiredCapabilityReason });
2376
+ }
2377
+
2166
2378
  export function parseLockFileStrict(file) {
2167
2379
  if (!existsSync(file)) return null;
2168
- const bad = (msg, extra = {}) => oatsError("invalid-lock", `${file}: ${msg}`, [{ file, violation: msg, ...extra }]);
2169
- let parsed;
2170
- try { parsed = JSON.parse(readFileSync(file, "utf8")); }
2171
- catch (e) { throw bad(`malformed JSON — ${e.message}`); }
2172
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw bad(`lock root must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
2173
- const v = parsed.lockfileVersion;
2174
- if (v !== undefined && typeof v !== "number") throw bad(`lockfileVersion must be a number (got ${JSON.stringify(v)})`);
2175
- if (v !== undefined && v !== 1 && v !== 2) throw bad(`unsupported lockfileVersion ${v}`);
2176
- const version = v ?? 1;
2177
- if (parsed.capabilities !== undefined && (parsed.capabilities === null || typeof parsed.capabilities !== "object" || Array.isArray(parsed.capabilities))) {
2178
- throw bad(`"capabilities" must be an object map (got ${parsed.capabilities === null ? "null" : Array.isArray(parsed.capabilities) ? "array" : typeof parsed.capabilities})`);
2179
- }
2180
- const out = { version, packages: Object.create(null), capabilities: Object.create(null), legacyCapabilities: Object.create(null) };
2181
- if (version === 1) {
2182
- if (parsed.packages !== undefined) throw bad(`lockfileVersion 1 must not carry a "packages" map`);
2183
- // Validate the COMPLETE v1 map before ANY consumer sees it. Retirement
2184
- // intentionally wins over shape validation (the user is told to delete that
2185
- // entry), but every non-retired entry is strict — that keeps restore
2186
- // preflight atomic and stops a malformed entry from granting the
2187
- // marketplace/hoisted exemption during discovery (reviewer-12e2d86).
2188
- for (const id of Object.keys(parsed.capabilities || {})) {
2189
- const entry = parsed.capabilities[id];
2190
- if (!retiredCapabilityReason(id)) {
2191
- const violation = legacyCapabilityEntryViolation(entry);
2192
- if (violation) throw bad(`legacy entry "${id}" is malformed (${violation})`, { package: id });
2193
- }
2194
- out.legacyCapabilities[id] = entry;
2195
- }
2196
- return out;
2197
- }
2198
- const rawPackages = parsed.packages;
2199
- if (rawPackages === undefined || rawPackages === null || typeof rawPackages !== "object" || Array.isArray(rawPackages)) {
2200
- throw bad(`lockfileVersion 2 requires a "packages" object map (got ${rawPackages === undefined ? "undefined" : rawPackages === null ? "null" : Array.isArray(rawPackages) ? "array" : typeof rawPackages})`);
2201
- }
2202
- const packageKeys = Object.keys(rawPackages);
2203
- const hasCapabilityMap = Object.hasOwn(parsed, "capabilities");
2204
- const stateFree = !packageKeys.length && (!hasCapabilityMap || !Object.keys(parsed.capabilities).length);
2205
- // UNSUPPORTED TRANSITIONAL v2 (contract §4.1) — an exact OR predicate, decided
2206
- // centrally before any discovery or mutation. It is never converted and never
2207
- // partially interpreted; the operator recreates the scope.
2208
- const unsupported = (why) => bad(`unsupported transitional package-root lockfileVersion 2 (${why}). This is the superseded package-store lock shape; it is not converted or interpreted. Delete this lock (and any .agents/packages directory) and recreate the scope's state with \`oats install\`.`);
2209
- if (!stateFree) {
2210
- if (!hasCapabilityMap) throw unsupported(`no top-level "capabilities" map`);
2211
- for (const id of packageKeys) {
2212
- const row = rawPackages[id];
2213
- if (!row || typeof row !== "object" || Array.isArray(row)) continue; // shape error reported below
2214
- const tells = TRANSITIONAL_ROW_FIELDS.filter((f) => Object.hasOwn(row, f));
2215
- if (tells.length) throw unsupported(`package row "${id}" carries ${tells.join(", ")}`);
2216
- }
2217
- }
2218
- for (const id of packageKeys) {
2219
- if (!PACKAGE_ID_RE.test(id)) throw bad(`packages map has an invalid package key ${JSON.stringify(id)}`, { package: id });
2220
- const e = rawPackages[id];
2221
- if (!e || typeof e !== "object" || Array.isArray(e)) throw bad(`lock entry for "${id}" is not an object`, { package: id });
2222
- const extra = Object.keys(e).filter((k) => !LOCK_PACKAGE_KEYS.has(k));
2223
- if (extra.length) throw bad(`lock entry for "${id}" has unknown keys: ${extra.join(", ")}`, { package: id });
2224
- out.packages[id] = e;
2225
- }
2226
- for (const id of Object.keys(out.packages)) validateLockEntry(id, out.packages[id], out.packages, { file });
2227
- // A state-free empty transitional document carries nothing, so it reads as the
2228
- // canonical empty lock rather than failing (contract §4.1).
2229
- if (hasCapabilityMap) {
2230
- for (const id of Object.keys(parsed.capabilities)) {
2231
- // BEFORE any filesystem join anywhere downstream: a revised-v2 capability
2232
- // key names a directory under installed/, so the grammar is enforced here,
2233
- // in the one central reader, rather than at each consumer.
2234
- if (!isMaterializedCapabilityId(id)) throw bad(`capabilities map has an invalid capability key: ${capabilityIdViolation(id)}`);
2235
- const e = parsed.capabilities[id];
2236
- if (!e || typeof e !== "object" || Array.isArray(e)) throw bad(`capability lock entry for "${id}" is not an object`, { package: id });
2237
- const extra = Object.keys(e).filter((k) => !LOCK_CAPABILITY_KEYS.has(k));
2238
- if (extra.length) throw bad(`capability lock entry for "${id}" has unknown keys: ${extra.join(", ")}`, { package: id });
2239
- out.capabilities[id] = e;
2240
- }
2241
- }
2242
- for (const id of Object.keys(out.capabilities)) validateCapabilityLockEntry(id, out.capabilities[id], out.packages, { file });
2243
- return out;
2380
+ return parseLockBytesStrict(readFileSync(file), { file });
2244
2381
  }
2245
2382
 
2246
2383
  /** Every lock-owning scope visible from a directory, outermost → innermost.
@@ -2753,108 +2890,13 @@ function pruneCreatedAnchors(createdAnchors) {
2753
2890
  }
2754
2891
  }
2755
2892
 
2756
- /** The generated provenance carried INSIDE every materialized artifact.
2757
- *
2758
- * Because the file is part of the artifact's integrity, a future kernel
2759
- * reprojecting the same locked bytes must produce it byte-for-byte. It therefore
2760
- * contains NOTHING about the writing kernel — only lock-, source- and
2761
- * manifest-derived values — and is serialized deterministically: exactly these
2762
- * keys in this order, two-space JSON, one trailing newline, mode 0644.
2763
- * `schemaVersion` is a constant of the format, bumped only by an explicit
2764
- * contract change (which is itself an integrity change, so it is visible). */
2765
- function capabilityInstallationRecord(cap, pkg) {
2766
- return {
2767
- schemaVersion: 1,
2768
- capability: cap.id,
2769
- version: cap.manifest.version,
2770
- package: pkg.package,
2771
- packageVersion: pkg.version,
2772
- source: pkg.source,
2773
- commit: pkg.commit,
2774
- packagePath: pkg.path,
2775
- capabilityPath: cap.rel,
2776
- };
2777
- }
2778
- function writeCapabilityInstallation(dest, cap, pkg) {
2779
- writeFileSync(join(dest, CAPABILITY_INSTALLATION_FILE), JSON.stringify(capabilityInstallationRecord(cap, pkg), null, 2) + "\n", { mode: 0o644 });
2780
- }
2781
- /** Read an artifact's provenance and check it AGREES with the lock rows it was
2782
- * projected from. Disagreement is invalid-lock: the artifact and the lock claim
2783
- * different origins, and neither may silently win. (A modified file also fails
2784
- * integrity, but this gives the precise diagnosis.) */
2785
- export function verifyCapabilityInstallation(dir, capabilityId, capRow, pkgRow) {
2786
- const file = join(dir, CAPABILITY_INSTALLATION_FILE);
2787
- if (!existsSync(file)) throw oatsError("invalid-lock", `materialized capability ${capabilityId} has no ${CAPABILITY_INSTALLATION_FILE} provenance — reproject it with \`oats install\``, [{ package: capabilityId, file }]);
2788
- let doc;
2789
- try { doc = JSON.parse(readFileSync(file, "utf8")); }
2790
- catch (e) { throw oatsError("invalid-lock", `materialized capability ${capabilityId} has malformed ${CAPABILITY_INSTALLATION_FILE}: ${e.message}`, [{ package: capabilityId, file }]); }
2791
- const expected = {
2792
- schemaVersion: 1, capability: capabilityId, version: capRow.version, package: capRow.package,
2793
- packageVersion: pkgRow.version, source: pkgRow.source, commit: pkgRow.commit,
2794
- packagePath: pkgRow.path, capabilityPath: capRow.path,
2795
- };
2796
- for (const [k, want] of Object.entries(expected)) {
2797
- if (doc?.[k] !== want) throw oatsError("invalid-lock", `materialized capability ${capabilityId}: ${CAPABILITY_INSTALLATION_FILE} "${k}" is ${JSON.stringify(doc?.[k])} but the lock records ${JSON.stringify(want)}`, [{ package: capabilityId, file, violation: k }]);
2798
- }
2799
- return doc;
2800
- }
2801
-
2802
- /** The executable surface a capability manifest declares. */
2803
- function executableSurfaceOf(manifest) {
2804
- return {
2805
- commands: Object.keys(manifest?.commands || {}),
2806
- hooks: Object.keys(manifest?.hooks || {}),
2807
- environment: [...(manifest?.environment || [])],
2808
- ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
2809
- };
2810
- }
2811
- function hasExecutableSurface(manifest) {
2812
- const s = executableSurfaceOf(manifest);
2813
- return s.commands.length > 0 || s.hooks.length > 0 || s.environment.length > 0;
2814
- }
2815
-
2816
- /** MATERIALIZE one capability out of a staged package payload into a flat,
2817
- * self-contained artifact (Decision: "Materialized capabilities are
2818
- * self-contained"). Everything happens in staging; nothing durable is touched.
2819
- *
2820
- * Order matters and is load-bearing:
2821
- * 1. materialize the capability's own runtime closure (npm ci, no scripts);
2822
- * 2. verify self-containment of every DECLARED resource, then symlink
2823
- * containment and the native-binary scan of the materialized closure —
2824
- * all against the CAPABILITY root, before any digest;
2825
- * 3. move the validated root into the artifacts area;
2826
- * 4. write .oats-installation.json provenance INSIDE the artifact;
2827
- * 5. hash the finished artifact.
2828
- * Hashing last is what makes provenance and closure tamper-evident.
2829
- */
2830
- function materializeCapability({ cap, pkg, artifactsDir }) {
2831
- const rep = materializeCapabilityDeps(cap.dir);
2832
- if (rep.error) throw oatsError("invalid-package-manifest", `runtime dependency materialization failed for capability "${cap.id}" of package "${pkg.package}": ${rep.error}`);
2833
- assertCapabilitySelfContained(cap.dir, cap.manifest);
2834
- assertMaterializedDepsContained(cap.dir);
2835
- assertNoNativeBinaries(cap.dir);
2836
- const dest = join(artifactsDir, cap.id);
2837
- mkdirSync(dirname(dest), { recursive: true });
2838
- if (cap.rel === ".") {
2839
- // Legacy flat layout: the capability root IS the staged package root, which
2840
- // other steps still need, so copy instead of moving it out. copyTreeSafe
2841
- // recreates symlinks verbatim — rewriting a link target would change bytes
2842
- // the integrity digest is about to cover — and stays catchable.
2843
- copyTreeSafe(cap.dir, dest);
2844
- } else renameSync(cap.dir, dest);
2845
- writeCapabilityInstallation(dest, cap, pkg);
2846
- return {
2847
- capability: cap.id, version: cap.manifest.version, package: pkg.package, path: cap.rel,
2848
- dir: dest, integrity: capabilityArtifactIntegrity(dest),
2849
- // The DECLARED fundamental layer, normalized to null when the capability
2850
- // declares none. A config template may bind a fundamental slot to one of the
2851
- // root package's own capabilities, and until the artifact is materialized
2852
- // there is nowhere else to read that from — so it belongs in the pre-commit
2853
- // preview, not in a post-commit validation with an outer rollback.
2854
- layer: cap.manifest.layer ?? null,
2855
- executableSurface: executableSurfaceOf(cap.manifest), manifest: cap.manifest,
2856
- };
2857
- }
2893
+ /** One materializer for old acquisition/restore and the new preparation adapter:
2894
+ * dependencies -> complete containment/native checks -> projection -> stable v1
2895
+ * installation provenance -> digest. No source/selection/trust policy moves here. */
2896
+ const materializeCapability = createCapabilityMaterializer({
2897
+ materializeCapabilityDeps, assertCapabilitySelfContained,
2898
+ assertMaterializedDepsContained, assertNoNativeBinaries,
2899
+ });
2858
2900
 
2859
2901
  /** Config-template descriptors AND payload bytes, read from a staged package
2860
2902
  * before staging is discarded, so the config lane can offer or adopt a template
@@ -2938,10 +2980,14 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2938
2980
  const { dir: staging, createdAnchors, ignore } = beginStaging(levelDir);
2939
2981
  const artifactsDir = join(staging, "artifacts");
2940
2982
  mkdirSync(artifactsDir, { recursive: true });
2941
- const resolved = new Map(); // identity → staged package record
2942
2983
  let counter = 0;
2943
- const resolveClosure = (srcSpec, chain, baseDir) => {
2984
+ const readPackage = (srcSpec, parent, chain) => {
2985
+ const baseDir = parent?.parsedSource.kind === "path" ? parent.parsedSource.path : undefined;
2944
2986
  const p = parsePackageSource(srcSpec, { baseDir });
2987
+ if (parent) {
2988
+ if (p.kind === "git" && !p.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${srcSpec}" (declared by ${parent.package})`);
2989
+ if (p.kind === "path" && p.relative && !baseDir) throw oatsError("invalid-source", `package dependency "${srcSpec}" (declared by ${parent.package}) is a relative path, but ${parent.package} was not acquired from a local path — relative dependencies only work between co-located local packages`);
2990
+ }
2945
2991
  const dest = join(staging, `pkg-${counter++}`);
2946
2992
  let commit, packagePath;
2947
2993
  if (!chain.length && opts.rootSnapshot) {
@@ -2962,7 +3008,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2962
3008
  } else ({ commit, path: packagePath } = fetchPackageSource(p, dest, opts.catalog));
2963
3009
  const m = loadPackageManifestAt(dest);
2964
3010
  const id = m.package;
2965
- if (chain.includes(id)) throw oatsError("dependency-cycle", `package dependency cycle: ${[...chain, id].join(" → ")}`, [...chain, id]);
2966
3011
  // Preserve the ORIGINAL catalog spec in lock metadata: bare and explicit
2967
3012
  // selector forms must remain distinguishable for update. The resolved git
2968
3013
  // commit is already pinned separately in `commit`.
@@ -2971,36 +3016,21 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2971
3016
  // contain several packages (contract §1.1), so two payload roots claiming one
2972
3017
  // package identity are a collision, not the same package resolved twice.
2973
3018
  const sourceKey = `${source}#${packagePath}`;
2974
- if (resolved.has(id)) {
2975
- const prev = resolved.get(id);
2976
- if (prev.sourceKey !== sourceKey) throw oatsError("duplicate-package-identity", `two sources claim package "${id}" at ${levelDir}: ${prev.sourceKey} and ${sourceKey}`, [prev.sourceKey, sourceKey]);
2977
- rmSync(dest, { recursive: true, force: true });
2978
- return id;
2979
- }
2980
- const compat = capabilityCompatibility(m);
2981
- if (!compat.compatible) throw oatsError("incompatible-oats", `package ${id} requires OATS ${compat.range} (running ${OATS_VERSION})`);
2982
- const deps = [];
2983
- for (const d of m.dependencies || []) {
2984
- // Relative local-path dependencies resolve against the DEPENDING PACKAGE'S
2985
- // source root (contract intent: package-relative), never the process CWD.
2986
- // For git/catalog parents there is no local base.
2987
- const depBase = p.kind === "path" ? p.path : undefined;
2988
- const dp = parsePackageSource(d, { baseDir: depBase });
2989
- if (dp.kind === "git" && !dp.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${d}" (declared by ${id})`);
2990
- // EVERY relative path dependency requires a local base — classified from
2991
- // the parsed payload so "path:sub" / whitespace spellings cannot resolve
2992
- // through the process CWD from a git/catalog manifest (reviewer-2a4adec).
2993
- if (dp.kind === "path" && dp.relative && !depBase) throw oatsError("invalid-source", `package dependency "${d}" (declared by ${id}) is a relative path, but ${id} was not acquired from a local path — relative dependencies only work between co-located local packages`);
2994
- deps.push(resolveClosure(d, [...chain, id], depBase));
2995
- }
2996
- resolved.set(id, {
3019
+ return {
2997
3020
  package: id, dir: dest, manifest: m, source, path: packagePath, sourceKey, commit,
2998
- version: m.version, integrity: packageIntegrity(dest), deps, capabilities: m._capabilities,
2999
- });
3000
- return id;
3021
+ version: m.version, capabilities: m._capabilities, parsedSource: p, dependencyRequests: m.dependencies || [],
3022
+ };
3001
3023
  };
3002
3024
  try {
3003
- const rootId = resolveClosure(spec, [], undefined);
3025
+ const { roots: [rootId], packages: resolved } = resolvePackageClosure({
3026
+ requests: [spec], readPackage, context: levelDir,
3027
+ validatePackage(record) {
3028
+ const compat = capabilityCompatibility(record.manifest);
3029
+ if (!compat.compatible) throw oatsError("incompatible-oats", `package ${record.package} requires OATS ${compat.range} (running ${OATS_VERSION})`);
3030
+ },
3031
+ finalizePackage: (record, deps) => ({ ...record, integrity: packageIntegrity(record.dir), deps }),
3032
+ discardPackage: (record) => rmSync(record.dir, { recursive: true, force: true }),
3033
+ });
3004
3034
  if (opts.expectPackage && rootId !== opts.expectPackage) {
3005
3035
  throw oatsError("duplicate-package-identity", `source ${spec} no longer provides root package "${opts.expectPackage}" (root resolved to "${rootId}")`);
3006
3036
  }
@@ -3187,97 +3217,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
3187
3217
  }
3188
3218
  }
3189
3219
 
3190
- /** Semantic lock-entry validation for a PACKAGE row (runtime API addendum §4):
3191
- * source/commit pairing, canonical path, dependency references (incl. self and
3192
- * cycle over the locked graph), digest shapes, uniqueness. Run BEFORE restore,
3193
- * trust/approval, update/remove/migration planning, the locked-template reader,
3194
- * and doctor/list consumption. Fails closed with code "invalid-lock" carrying
3195
- * file/package provenance; never normalizes or auto-repairs on read. */
3196
- export function validateLockEntry(packageId, entry, allPackages = {}, opts = {}) {
3197
- const where = opts.file ? ` (${opts.file})` : "";
3198
- const bad = (msg) => oatsError("invalid-lock", `lock entry for package "${packageId}"${where} is invalid: ${msg}`, [{ package: packageId, file: opts.file, violation: msg }]);
3199
- if (!entry || typeof entry !== "object") throw bad("not an object");
3200
- for (const k of ["source", "path", "version", "commit", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
3201
- // The selected package root is a STRICT separate field (contract §1.1) stored
3202
- // in canonical form only — a lock is never normalized or repaired on read, so
3203
- // a non-canonical spelling ("./sub", "sub/", "") is invalid, not silently
3204
- // accepted. That is what makes the root representation round-trip.
3205
- {
3206
- let canonical;
3207
- try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
3208
- catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
3209
- if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
3210
- }
3211
- if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
3212
- // Present-but-wrong-typed optional fields are invalid — default ONLY when absent.
3213
- // `dependencies` is ALWAYS recorded (empty array when none), so a reader never
3214
- // has to distinguish absent from empty.
3215
- if (!Array.isArray(entry.dependencies)) throw bad("dependencies must be an array (empty when the package has none)");
3216
- for (const d of entry.dependencies) if (typeof d !== "string" || !PACKAGE_ID_RE.test(d)) throw bad(`dependencies contains an invalid package id ${JSON.stringify(d)}`);
3217
- if (new Set(entry.dependencies).size !== entry.dependencies.length) throw bad("dependencies contains duplicates");
3218
- // Package rows lock transport only. A capability list, a trust list or a
3219
- // dependency-closure digest here is the unsupported transitional shape — the
3220
- // central parser rejects those documents outright (contract §4.1); this is
3221
- // the entry-level backstop for a row reaching validation another way.
3222
- for (const gone of TRANSITIONAL_ROW_FIELDS) {
3223
- if (Object.hasOwn(entry, gone)) throw bad(`"${gone}" is a transitional package-root field — package rows lock transport only (capabilities and trust live on capability rows)`);
3224
- }
3225
- let src;
3226
- try { src = parseLockSource(entry.source); } catch { throw bad(`unrecognized source "${entry.source}"`); }
3227
- if (src.kind === "path" && !src.path) throw bad("empty path source");
3228
- if (src.kind === "git" && !src.url) throw bad("empty git source");
3229
- if (src.kind === "catalog" && !src.id) throw bad("empty catalog source");
3230
- if (src.kind === "path") {
3231
- if (entry.commit !== "local") throw bad(`path source requires commit "local", got "${entry.commit}"`);
3232
- // Local acquisition is exact-directory: the source string already names the
3233
- // package root, so the only valid contained path is the root itself.
3234
- if (entry.path !== ".") throw bad(`path source requires path "." (local sources are exact directories), got ${JSON.stringify(entry.path)}`);
3235
- }
3236
- else if (!/^[0-9a-f]{40}$/.test(entry.commit)) throw bad(`${src.kind} source requires an exact 40-hex commit, got "${entry.commit}"`);
3237
- for (const d of entry.dependencies || []) {
3238
- if (d === packageId) throw bad(`self-dependency "${d}"`);
3239
- // Object.hasOwn: a dependency literally named "constructor"/"__proto__"
3240
- // must not pass via Object.prototype.
3241
- if (!Object.hasOwn(allPackages, d)) throw bad(`dependency "${d}" is not locked in the same packages map`);
3242
- }
3243
- // Cycle over the locked dependency graph reachable from this entry.
3244
- const visiting = new Set();
3245
- const visited = new Set();
3246
- const walk = (id, chain) => {
3247
- if (visited.has(id)) return;
3248
- if (visiting.has(id)) throw bad(`dependency cycle in the locked graph: ${[...chain, id].join(" → ")}`);
3249
- visiting.add(id);
3250
- const deps = Object.hasOwn(allPackages, id) && Array.isArray(allPackages[id]?.dependencies) ? allPackages[id].dependencies : [];
3251
- for (const d of deps) if (Object.hasOwn(allPackages, d) || d === packageId) walk(d, [...chain, id]);
3252
- visiting.delete(id); visited.add(id);
3253
- };
3254
- walk(packageId, []);
3255
- return true;
3256
- }
3257
-
3258
- /** Semantic validation of one CAPABILITY row against the whole document
3259
- * (contract §4). The `package` back-reference must name a locked package: it is
3260
- * the single provider truth, so a dangling reference would leave a materialized
3261
- * artifact with no provenance to restore or verify it from. */
3262
- export function validateCapabilityLockEntry(capabilityId, entry, allPackages = {}, opts = {}) {
3263
- const where = opts.file ? ` (${opts.file})` : "";
3264
- const bad = (msg) => oatsError("invalid-lock", `capability lock entry for "${capabilityId}"${where} is invalid: ${msg}`, [{ package: capabilityId, file: opts.file, violation: msg }]);
3265
- if (typeof capabilityId !== "string" || !capabilityId) throw bad("empty capability id");
3266
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw bad("not an object");
3267
- for (const k of ["version", "package", "path", "integrity"]) if (!entry[k] || typeof entry[k] !== "string") throw bad(`missing ${k}`);
3268
- if (!PACKAGE_ID_RE.test(entry.package)) throw bad(`provider package ${JSON.stringify(entry.package)} is not a valid package identity`);
3269
- if (!Object.hasOwn(allPackages, entry.package)) throw bad(`provider package "${entry.package}" is not locked in the same packages map`);
3270
- {
3271
- let canonical;
3272
- try { canonical = normalizePackagePath(entry.path, { where: "path", code: "invalid-lock" }); }
3273
- catch (e) { throw bad(`invalid path ${JSON.stringify(entry.path)} — ${e.message}`); }
3274
- if (canonical !== entry.path) throw bad(`path ${JSON.stringify(entry.path)} is not in canonical form (expected ${JSON.stringify(canonical)})`);
3275
- }
3276
- if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) throw bad(`malformed integrity "${entry.integrity}"`);
3277
- if (typeof entry.trusted !== "boolean") throw bad(`"trusted" must be a boolean (got ${JSON.stringify(entry.trusted)})`);
3278
- return true;
3279
- }
3280
-
3281
3220
  /** Fetch ONE locked package's exact provenance into staging and return the
3282
3221
  * verified payload root. Used by restore, update planning, migration and the
3283
3222
  * locked-template reader — every path that needs the exact bytes a package row
@@ -3661,33 +3600,27 @@ export function readLockedConfigTemplates(startDir, packageId, opts = {}) {
3661
3600
  } finally { rmSync(tmp, { recursive: true, force: true }); }
3662
3601
  }
3663
3602
 
3664
- /** Validate one LEGACY (lockfileVersion 1) capability lock entry against the v1
3665
- * schema shape; returns null when valid or a violation string.
3666
- *
3667
- * Engine-internal: the strict reader uses it so a malformed v1 entry is refused
3668
- * before any consumer sees the map. It is deliberately NOT a "residue" check —
3669
- * migration produces no residue, and the superseded transitional v2 shape is
3670
- * rejected wholesale rather than partially parsed. */
3671
- export function legacyCapabilityEntryViolation(entry) {
3672
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "not an object";
3673
- for (const k of ["source", "version", "integrity"]) if (typeof entry[k] !== "string" || !entry[k]) return `missing/invalid ${k}`;
3674
- if (!/^sha256-[0-9a-f]{64}$/.test(entry.integrity)) return `malformed integrity "${entry.integrity}"`;
3675
- if (entry.commit !== undefined && typeof entry.commit !== "string") return "invalid commit";
3676
- if (entry.trustedExecutables !== undefined && typeof entry.trustedExecutables !== "boolean") return "invalid trustedExecutables";
3677
- return null;
3678
- }
3679
-
3680
3603
  /** Atomic file replacement: write to a same-directory temp file, then rename
3681
3604
  * over the destination — an interrupted write leaves the original bytes intact
3682
3605
  * (reviewer-21849d4). */
3683
- function atomicWriteFileSync(file, content) {
3606
+ function atomicWriteFileSync(file, content, { assertRoots } = {}) {
3684
3607
  const tmp = join(dirname(file), `.${basename(file)}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
3608
+ let written = false;
3685
3609
  try {
3686
- writeFileSync(tmp, content);
3610
+ assertRoots?.();
3611
+ writeFileSync(tmp, content, assertRoots ? { flag: "wx", mode: 0o600 } : undefined); written = true;
3612
+ assertRoots?.();
3687
3613
  renameSync(tmp, file);
3688
- } catch (e) {
3689
- rmSync(tmp, { force: true });
3690
- throw e;
3614
+ } catch (error) {
3615
+ // Never follow a replacement parent to clean up a same-named temp file.
3616
+ // Unguarded legacy callers retain their existing behavior.
3617
+ if (assertRoots && !written) throw error;
3618
+ try { assertRoots?.(); rmSync(tmp, { force: true }); }
3619
+ catch (cleanup) {
3620
+ const failure = new AggregateError([error, cleanup], "metadata publication and owned cleanup failed", { cause: error });
3621
+ failure.code = error.code || cleanup.code; throw failure;
3622
+ }
3623
+ throw error;
3691
3624
  }
3692
3625
  }
3693
3626
 
@@ -4170,15 +4103,8 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
4170
4103
  if (cap.inject && existsSync(cap.inject)) wanted.push([`capability:${cap.id}`, cap.inject]);
4171
4104
  }
4172
4105
  for (const inj of resolved.injects) wanted.push([`config:${inj.source}`, inj.file]);
4173
- let text = readFileSync(agentsMd, "utf8").replace(/\n*$/, "\n");
4174
- const blocks = [];
4175
- for (const [source, file] of wanted) {
4176
- const content = readFileSync(file, "utf8").trim();
4177
- const block = `<!-- oats:${source} src=${file} -->\n${content}\n<!-- /oats:${source} -->`;
4178
- text += `\n${block}\n`;
4179
- blocks.push({ source, file, content });
4180
- }
4181
- return { text, blocks, resolved };
4106
+ const blocks = wanted.map(([source, file]) => ({ source, file, content: readFileSync(file, "utf8").trim() }));
4107
+ return { text: renderInstructionText(readFileSync(agentsMd, "utf8"), blocks), blocks, resolved };
4182
4108
  }
4183
4109
 
4184
4110
  /** The skill entries a tree contributes — THE discovery rule, shared by preflight
@@ -4726,6 +4652,187 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4726
4652
  return results;
4727
4653
  }
4728
4654
 
4655
+ /** Derive one selected supplemental input from the exact hook owner's binding
4656
+ * and already-validated generic target. Never infer dependency from a slot/name. */
4657
+ function capturedHookSourceReceipt(loaded) {
4658
+ const { record, capability, invocation } = loaded, target = invocation?.instance;
4659
+ const declaration = capability && hookDeclaration(capability.manifest.hooks?.[invocation?.action.name]);
4660
+ if (!target || invocation.action.kind !== "hook" || invocation.capability !== capability.id || declaration?.inputs?.sourceReceipt?.version !== 1) throw oatsError("invalid-resolution", "source receipt requires an opted-in exact owned hook target");
4661
+ const binding = record.bindings[capability.manifest.layer];
4662
+ if (!binding || binding.capability !== capability.id) throw oatsError("needs-configuration", "source receipt requires the opting capability's actual selected binding");
4663
+ const roleFile = loaded.resources.get(record.dispatch.composition?.body);
4664
+ if (!roleFile) throw oatsError("invalid-resolution", "selected source input lacks its canonical body resource");
4665
+ return validateCapturedSourceReceipt({ schemaVersion: 1, kind: record.subject.kind,
4666
+ home: target.home, work: target.work, context: invocation.executionBinding.deployment,
4667
+ agent: target.agent, instance: target.name, sourceIdentity: record.subject.kind === "persistent" ? record.subject.soul.identity : null,
4668
+ role: decodeUtf8(readPortableBytes(roleFile)), executionBinding: invocation.executionBinding,
4669
+ responsibleHuman: invocation.responsibleHuman, binding });
4670
+ }
4671
+
4672
+ /** Captured lifecycle execution uses the same hook result contract, but every
4673
+ * executable/settings/binding input comes from one verified resolution. It
4674
+ * preflights all applicable hooks before the first lifecycle side effect. */
4675
+ export function runCapturedLifecycleHooks(event, { deployment, resolution, home, instance, agentName, priorMeta = {}, extraEnv = {}, sourceReceipt, assertRoots, retryIntents = {} }) {
4676
+ if (!APPROVED_HOOKS.has(event)) throw oatsError("unsupported-action", `unsupported captured lifecycle event ${JSON.stringify(event)}`);
4677
+ for (const [name, value] of [["home", home], ["deployment", deployment]]) if (typeof value !== "string" || !isAbsolute(value)) throw oatsError("invalid-declaration", `${name} must be absolute`);
4678
+ if (typeof instance !== "string" || !instance || typeof agentName !== "string" || !agentName) throw oatsError("invalid-declaration", "captured lifecycle needs instance and agent names");
4679
+ if (sourceReceipt !== undefined) validateCapturedSourceReceipt(sourceReceipt);
4680
+ const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
4681
+ if (sourceReceipt) {
4682
+ const { record } = inspected, persistent = record.subject.kind === "persistent";
4683
+ const expectedAgent = persistent ? record.subject.soul.alias : record.subject.name;
4684
+ const expectedIdentity = persistent ? record.subject.soul.identity : null;
4685
+ const expectedHuman = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
4686
+ const expectedBinding = Object.values(record.bindings).find((binding) => binding.capability === sourceReceipt.binding.capability);
4687
+ const bodyKey = record.dispatch.composition?.body, bodyFile = bodyKey ? inspected.resources.get(bodyKey) : undefined;
4688
+ let role; try { role = bodyFile ? readFileSync(bodyFile, "utf8") : undefined; } catch { role = undefined; }
4689
+ let sameDeployment = false;
4690
+ try { sameDeployment = portableScope(sourceReceipt.executionBinding.deployment) === portableScope(deployment); } catch { /* invalid scope refuses below */ }
4691
+ if (sourceReceipt.kind !== (persistent ? "persistent" : "helper") || sourceReceipt.home !== resolve(home)
4692
+ || sourceReceipt.work !== join(resolve(home), "work") || sourceReceipt.agent !== expectedAgent || agentName !== expectedAgent || sourceReceipt.instance !== instance
4693
+ || sourceReceipt.executionBinding.resolution.id !== resolution.id || !sameDeployment
4694
+ || canonicalJson(sourceReceipt.sourceIdentity) !== canonicalJson(expectedIdentity)
4695
+ || canonicalJson(sourceReceipt.responsibleHuman) !== canonicalJson(expectedHuman)
4696
+ || !expectedBinding || canonicalJson(sourceReceipt.binding) !== canonicalJson(expectedBinding)
4697
+ || role === undefined || sourceReceipt.role !== role) {
4698
+ throw oatsError("invalid-resolution", "captured source receipt differs from the verified resolution or lifecycle target");
4699
+ }
4700
+ }
4701
+ const ids = Object.keys(inspected.record.dispatch.providerManifests);
4702
+ if (event === "retire") ids.reverse();
4703
+ const entries = ids.flatMap((id) => {
4704
+ const capability = inspected.capabilities.get(id), declaration = hookDeclaration(capability?.manifest?.hooks?.[event]);
4705
+ return declaration ? [{ id, capability, declaration }] : [];
4706
+ });
4707
+ if (sourceReceipt && !entries.some(({ id, declaration }) => id === sourceReceipt.binding.capability && declaration.inputs?.sourceReceipt?.version === 1)) throw oatsError("invalid-resolution", "explicit source receipt requires a matching opted-in hook owner");
4708
+ objectAt(retryIntents, entries.map(({ id }) => id), []);
4709
+ const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], intents: {} };
4710
+ const checkRoots = () => {
4711
+ try { assertRoots?.(); }
4712
+ catch (error) { error.capturedHooks = { meta: results.meta, intents: results.intents, failures: results.failures, order: results.order }; throw error; }
4713
+ };
4714
+ const envOwners = new Map(), envDeclarations = new Map(entries.map(({ id, capability }) => [id, {
4715
+ names: new Set(capability.manifest.environment || []), namespaces: [...(capability.manifest.environmentNamespaces || [])],
4716
+ }]));
4717
+ const ready = [];
4718
+ for (const entry of entries) {
4719
+ const options = { deployment, resolution, action: { kind: "hook", capability: entry.id, name: event },
4720
+ invocationTarget: { home, work: join(home, "work"), name: instance, agent: agentName },
4721
+ ...(Object.hasOwn(priorMeta, entry.id) ? { priorReceipt: priorMeta[entry.id] } : {}) };
4722
+ let staticLoaded;
4723
+ try {
4724
+ // Check every static source/target/approval/executable before any effects.
4725
+ // Readiness is action-specific and receives its admitted intent below.
4726
+ staticLoaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
4727
+ } catch (error) {
4728
+ const detail = String(error.message || error).slice(0, 200);
4729
+ results.warnings.push(`${entry.id} ${event} hook unavailable: ${detail}`);
4730
+ results.failures.push({ capability: entry.id, event, message: detail, required: entry.declaration.required });
4731
+ if (event === "spawn" && entry.declaration.required) return results;
4732
+ continue;
4733
+ }
4734
+ // Selected-input authority failures are not optional hook functionality.
4735
+ // Derive ALL requested owner inputs before any provider check/effect.
4736
+ const selectedReceipt = entry.declaration.inputs?.sourceReceipt ? capturedHookSourceReceipt(staticLoaded) : null;
4737
+ if (sourceReceipt?.binding.capability === entry.id && canonicalJson(sourceReceipt) !== canonicalJson(selectedReceipt)) throw oatsError("invalid-resolution", "explicit receipt differs from the derived opted-in input");
4738
+ ready.push({ ...entry, options, selectedReceipt, instanceFacts: staticLoaded.invocation.instance });
4739
+ }
4740
+ for (const { id, capability, declaration, options, selectedReceipt, instanceFacts } of ready) {
4741
+ checkRoots(); results.order.push(id);
4742
+ const env = { ...process.env };
4743
+ for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
4744
+ Object.assign(env, extraEnv, {
4745
+ OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
4746
+ OATS_CAPABILITY: id, OATS_CAPABILITY_ROOT: capability.manifest._dir, OATS_LAYER: capability.manifest.layer || "",
4747
+ OATS_CONTEXT: deployment, OATS_WORKSPACE: deployment, OATS_LEVEL: deployment,
4748
+ OATS_DEPLOYMENT: deployment, OATS_RESOLUTION: resolution.id,
4749
+ OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")), OATS_SETTINGS: JSON.stringify(capability.settings),
4750
+ OATS_META: JSON.stringify(priorMeta[id] || {}),
4751
+ });
4752
+ // Caller extras cannot nominate an input file, including when no owner
4753
+ // selected that input. Only private wrappers below supply these variables.
4754
+ for (const key of ["OATS_SOURCE_RECEIPT_FILE", "OATS_INVOCATION_CONTEXT_FILE", "OATS_BINDING_FILE"]) delete env[key];
4755
+ let admission, started = false;
4756
+ try {
4757
+ admission = admitCapturedAction({ deployment, resolution, home, action: options.action,
4758
+ ...(Object.hasOwn(priorMeta, id) ? { priorReceipt: priorMeta[id] } : {}),
4759
+ ...(Object.hasOwn(retryIntents, id) ? { retryExecutionId: retryIntents[id] } : {}) });
4760
+ results.intents[id] = admission.intent;
4761
+ if (admission.replayed) {
4762
+ // Never re-execute completed hooks to reconstruct runtime contributions.
4763
+ if (!admission.replayable) throw oatsError("needs-configuration", "completed hook has non-replayable runtime contributions");
4764
+ if (admission.receipt !== null) results.meta[id] = admission.receipt;
4765
+ continue;
4766
+ }
4767
+ const admittedOptions = { ...options, intent: admission.intent, priorReceipt: admission.receipt };
4768
+ const admitted = loadCapturedAction(admittedOptions, capturedDispatchCodecs(admittedOptions, { admitting: true }));
4769
+ if (canonicalJson(admitted.invocation.instance) !== canonicalJson(instanceFacts)) throw oatsError("selection-changed", "hook incarnation changed after input preflight");
4770
+ if (selectedReceipt && canonicalJson(capturedHookSourceReceipt(admitted)) !== canonicalJson(selectedReceipt)) throw oatsError("selection-changed", "selected source input changed after admission");
4771
+ const loaded = loadCapturedDispatch(admittedOptions);
4772
+ env.OATS_META = JSON.stringify(admission.receipt ?? {});
4773
+ const invoke = (snapshotEnv = {}) => withCapturedInvocationContextFile(loaded.invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => {
4774
+ checkRoots();
4775
+ beginCapturedIntent({ deployment, home, intent: admission.intent, action: options.action }); started = true;
4776
+ return execFileSync(process.execPath, [loaded.executable.file, ...loaded.executable.args], { cwd: home, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, killSignal: "SIGKILL", env: { ...env, ...snapshotEnv, ...contextEnv, ...bindingEnv } });
4777
+ }));
4778
+ const stdout = selectedReceipt ? withCapturedSourceReceiptFile(home, selectedReceipt, invoke) : invoke();
4779
+ const lastLine = String(stdout).trim().split("\n").filter(Boolean).pop() || "{}";
4780
+ let output = {}; try { output = JSON.parse(lastLine); } catch { /* non-JSON hook output is allowed */ }
4781
+ if (output.meta) results.meta[id] = output.meta;
4782
+ if (output.brief) results.briefs.push(`- ${output.brief}`);
4783
+ if (output.warning) results.warnings.push(output.warning);
4784
+ if (output.launch && typeof output.launch === "object") for (const [runtime, values] of Object.entries(output.launch)) results.launch[runtime] = `${results.launch[runtime] ? `${results.launch[runtime]} ` : ""}${values}`;
4785
+ if (event === "launch" || (output.launch && typeof output.launch === "object" && Object.keys(output.launch).length) || (output.env && typeof output.env === "object" && Object.keys(output.env).length)) {
4786
+ results.contributions.push({ capability: id, layer: capability.manifest.layer || null, level: deployment, settings: { ...capability.settings },
4787
+ trust: { trusted: true, integrity: inspected.record.artifacts.capabilities[id].artifact.integrity.value },
4788
+ launch: output.launch && typeof output.launch === "object" ? { ...output.launch } : {}, env: output.env && typeof output.env === "object" ? Object.keys(output.env).sort() : [] });
4789
+ }
4790
+ if (output.env !== undefined) {
4791
+ if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${id} hook env is supported only for spawn and launch, not ${event}`);
4792
+ Object.assign(results.env, validateHookEnvironment(id, output.env, envOwners, envDeclarations));
4793
+ }
4794
+ settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action, state: "completed",
4795
+ receipt: results.meta[id] ?? admission.receipt,
4796
+ replayable: !Object.keys(output.env || {}).length && !Object.keys(output.launch || {}).length });
4797
+ } catch (error) {
4798
+ // A nested private-snapshot cleanup failure must not erase a hook's
4799
+ // already-observed provider receipt. Unwrap bounded owned error causes;
4800
+ // never reinterpret that receipt as a successful/fully cleaned lifecycle.
4801
+ let observed, reported;
4802
+ const seen = new Set(); let cause = error;
4803
+ while (cause && typeof cause === "object" && !seen.has(cause) && seen.size < 16) {
4804
+ seen.add(cause);
4805
+ if (cause.invocationCompleted && typeof cause.invocationResult === "string") { observed = cause.invocationResult; break; }
4806
+ if (typeof cause.stdout === "string" || Buffer.isBuffer(cause.stdout)) { observed = cause.stdout; break; }
4807
+ cause = cause.cause;
4808
+ }
4809
+ try {
4810
+ const output = JSON.parse(String(observed ?? "").trim().split("\n").filter(Boolean).pop() || "{}");
4811
+ if (output?.meta) results.meta[id] = output.meta;
4812
+ if (typeof output?.warning === "string" && output.warning.trim()) reported = output.warning.trim();
4813
+ } catch { /* non-JSON hook failure output is allowed */ }
4814
+ let custodyError;
4815
+ if (admission && !admission.replayed) {
4816
+ try { settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action,
4817
+ state: started ? "unconfirmed" : "blocked", receipt: results.meta[id] ?? admission.receipt }); }
4818
+ catch (failure) { custodyError = failure; } // Possibly published: retain, never erase/re-admit.
4819
+ }
4820
+ const environment = error instanceof HookEnvironmentContractError;
4821
+ const cleanup = error instanceof AggregateError;
4822
+ const required = environment || cleanup || !!custodyError || declaration.required;
4823
+ const detail = reported || String(error.message || error).slice(0, 200);
4824
+ results.warnings.push(`${id} ${event} hook ${environment ? "environment contract " : ""}failed: ${detail}`);
4825
+ results.failures.push({ capability: id, event, message: detail, required,
4826
+ ...(started || custodyError ? { unconfirmed: true } : {}),
4827
+ ...(custodyError ? { custody: { code: custodyError.code || "E_INTENT_CUSTODY", message: String(custodyError.message).slice(0, 200) } } : {}),
4828
+ ...(environment ? { contract: "environment" } : {}),
4829
+ ...(cleanup ? { contract: "snapshot-cleanup", unconfirmed: true, cleanup: { code: error.code || "E_HOOK_CLEANUP", message: String(error.message || error).slice(0, 200) } } : {}) });
4830
+ if (event === "spawn" && required) return results;
4831
+ } finally { checkRoots(); }
4832
+ }
4833
+ return results;
4834
+ }
4835
+
4729
4836
  // ---------- agents ----------
4730
4837
  /** All local-agent base dirs readable for a root: the scope sibling (canonical)
4731
4838
  * plus legacy nested locations. */
@@ -5452,7 +5559,9 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5452
5559
  const hookArgs = recipe.hooks?.launch?.[runtime] || "";
5453
5560
  const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
5454
5561
  let cmdline;
5455
- if (runtime === "claude") {
5562
+ if (isPiSdkHost(recipe)) {
5563
+ cmdline = `${shq(executable)} ${piHostArgv(recipe, { home, sessionDir: capturedPiSessionDirectory(home) }).map(shq).join(" ")}`;
5564
+ } else if (runtime === "claude") {
5456
5565
  cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5457
5566
  } else if (runtime === "codex") {
5458
5567
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
@@ -5472,15 +5581,27 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5472
5581
  /** Execution, not preview: mark pending before dispatch, then resolve native
5473
5582
  * storage inside the backend shell under the actual command environment.
5474
5583
  * The original executable/argv is exec'd unchanged after recording succeeds. */
5475
- function nativeRecordCommand(command, home, runtime) {
5584
+ function nativeRecordCommand(command, home, runtime, onPrepared, prepare = prepareNativeStart) {
5476
5585
  const { tokens, binary } = parseLaunchCommand(command);
5477
5586
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
5478
- const id = prepareNativeStart(home, runtime);
5587
+ const id = prepare(home, runtime);
5588
+ onPrepared?.(id);
5479
5589
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
5480
5590
  const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(runtime)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
5481
5591
  return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
5482
5592
  }
5483
5593
 
5594
+ /** Common tmux/Herdr completion wrapper for the exact captured Pi SDK profile.
5595
+ * Save the actual native chain's shell status BEFORE the observer. Reuse the
5596
+ * original command environment (including its ORIGINAL attempt); no new intent
5597
+ * or process identity is constructed. The observer never replaces this status. */
5598
+ export function renderCapturedPiCompletion(command, executionCommand, { home, nativeRecordId }) {
5599
+ const { tokens, binary } = parseLaunchCommand(command);
5600
+ const prefix = tokens.slice(0, binary).map(token => token.text).join(" ");
5601
+ const args = ["--oats-pi-record-exit", "1", "--home", home, "--native-record", nativeRecordId].map(shq).join(" ");
5602
+ return `${executionCommand}; oats_start_status=$?; ${prefix} ${shq(process.execPath)} ${shq(PI_SDK_HOST)} ${args} --exit-status "$oats_start_status"`;
5603
+ }
5604
+
5484
5605
  /** A recorded recipe this kernel understands, or a refusal before anything
5485
5606
  * is observed or stopped. */
5486
5607
  export function assertLaunchRecipe(recipe, what) {
@@ -6744,6 +6865,7 @@ function sessionDirectoryGuard(home) {
6744
6865
  let meta;
6745
6866
  try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
6746
6867
  if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
6868
+ if (baseline.executionBinding && (meta.incarnationId !== baseline.incarnationId || canonicalJson(meta.executionBinding ?? null) !== canonicalJson(baseline.executionBinding))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "captured native baseline differs from current incarnation binding");
6747
6869
  };
6748
6870
  check();
6749
6871
  return check;
@@ -6953,13 +7075,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
6953
7075
  return roots;
6954
7076
  }
6955
7077
 
6956
- function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
7078
+ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
6957
7079
  if (mode === "directory") assertDirectoryRoots(home);
6958
7080
  const isWorktree = mode === "worktree";
6959
7081
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
6960
7082
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
6961
7083
  const baseline = {
6962
7084
  version: RETIRE_BASELINE_VERSION,
7085
+ ...(incarnationId ? { incarnationId, executionBinding } : {}),
6963
7086
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
6964
7087
  home: realPathOrNearest(home),
6965
7088
  homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
@@ -6973,7 +7096,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
6973
7096
  };
6974
7097
  const path = retirementBaselinePath(home);
6975
7098
  mkdirSync(dirname(path), { recursive: true });
6976
- writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
7099
+ writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600, ...(exclusive ? { flag: "wx" } : {}) });
6977
7100
  }
6978
7101
 
6979
7102
  function nestedGitRoots(root) {
@@ -7024,9 +7147,11 @@ function runtimeAuthorityOf(baseline) {
7024
7147
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
7025
7148
  }
7026
7149
 
7027
- /** Session control uses the same independent endpoint receipt as retirement. */
7150
+ /** Session control uses the independent endpoint receipt and, for captured
7151
+ * homes, its original incarnation's existing home/work custody. */
7028
7152
  function instanceSessionTarget(home) {
7029
7153
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
7154
+ const requestedHome = home;
7030
7155
  home = realPathOrNearest(home);
7031
7156
  let baseline, meta;
7032
7157
  try {
@@ -7035,18 +7160,29 @@ function instanceSessionTarget(home) {
7035
7160
  } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
7036
7161
  const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
7037
7162
  if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
7163
+ let io;
7164
+ if ([baseline, meta].some(value => ["executionBinding", "incarnationId", "captured"].some(key => Object.hasOwn(value, key)))) {
7165
+ // The independent receipt remains the anchor even if mutable home markers
7166
+ // are removed. Missing/invalid captured evidence never falls back to legacy.
7167
+ validateExecutionBinding(baseline.executionBinding);
7168
+ const { row } = readCapturedInstanceAuthority(baseline.executionBinding.deployment, requestedHome);
7169
+ if (row.incarnationId !== baseline.incarnationId || canonicalJson(row.executionBinding) !== canonicalJson(baseline.executionBinding)) {
7170
+ throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "captured incarnation disagrees with independent session receipt");
7171
+ }
7172
+ io = { exec: (...args) => { assertCapturedInstanceCustody(row); return execFileSync(...args); } };
7173
+ }
7038
7174
  const endpointAgrees = authority.sessionTarget
7039
7175
  ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
7040
7176
  : !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
7041
7177
  if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
7042
- return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
7178
+ return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined, io };
7043
7179
  }
7044
7180
 
7045
7181
  export function inspectInstanceSession(home) {
7046
7182
  if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
7047
7183
  const s = instanceSessionTarget(home);
7048
7184
  if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
7049
- try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
7185
+ try { return { home: s.home, ...inspectSessionTarget(s.target, s.io) }; }
7050
7186
  catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
7051
7187
  }
7052
7188
 
@@ -7060,7 +7196,7 @@ export function inputInstanceSession(home, text) {
7060
7196
  if (typeof text !== "string" || !text.trim() || text.includes("\0") || Buffer.byteLength(text) > 256 * 1024) throw oatsError("E_BAD_ARGS", "session input must be nonempty text without NUL, at most 256 KiB");
7061
7197
  const s = instanceSessionTarget(home);
7062
7198
  if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
7063
- try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
7199
+ try { return { home: s.home, ...inputSessionTarget(s.target, text, s.io) }; }
7064
7200
  catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
7065
7201
  }
7066
7202
 
@@ -7431,12 +7567,251 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
7431
7567
  * per-home lock and pending receipt across stop and launch, every preflight
7432
7568
  * before the stop, a bounded SIGTERM with no escalation, factual receipts.
7433
7569
  * Not a retirement: home, work, identity and notes stay. */
7570
+ /** Exact captured plan, existing native session transaction. This is dispatch
7571
+ * custody, not provider enrollment or proof that a model finished its task. */
7572
+ export function startCapturedInstanceSession(home, options = {}) {
7573
+ objectAt(options, ["deployment", "resolution", "backend", "task", "retryExecutionId", "restart", "env", "io", "stopGraceMs"], []);
7574
+ if (options.stopGraceMs !== undefined && (!Number.isSafeInteger(options.stopGraceMs) || options.stopGraceMs < 1 || options.stopGraceMs > 300000)) throw oatsError("E_BAD_ARGS", "captured stop grace must be 1-300000 milliseconds");
7575
+ let metadata = readCapturedInstanceMetadata(home);
7576
+ home = metadata.home;
7577
+ const binding = metadata.executionBinding, deployment = portableScope(options.deployment ?? binding.deployment), resolution = options.resolution ?? binding.resolution;
7578
+ if (canonicalJson({ schemaVersion: 1, deployment, resolution }) !== canonicalJson(binding)) throw oatsError("invalid-resolution", "captured start selector differs from the owned home");
7579
+ const original = readCapturedInstanceAuthority(deployment, home).row;
7580
+ if (!original.nativeScaffold || canonicalJson(metadata.captured.nativeScaffold ?? null) !== canonicalJson(original.nativeScaffold)) throw oatsError("migration-required", "captured home lacks fresh native scaffold evidence; no history backfill was performed");
7581
+ const latestSession = original.intents.findLast(intent => intent.action.kind === "session");
7582
+ const heldLifecycle = (message, indexedStatus = original.status) => {
7583
+ const error = oatsError("selection-changed", message);
7584
+ error.home = home;
7585
+ error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: binding,
7586
+ indexedStatus, metadataStatus: metadata.captured.lifecycle, held: true,
7587
+ ...(latestSession ? { intent: { schemaVersion: 1, executionId: latestSession.executionId, incarnationId: original.incarnationId, attempt: latestSession.attempt }, receipt: latestSession.receipt } : {}) };
7588
+ throw error;
7589
+ };
7590
+ const sourceStatus = metadata.captured.lifecycle;
7591
+ if (original.status !== sourceStatus || !["spawned-launch-pending", "start-dispatched", "start-failed-cleanup-required"].includes(sourceStatus)) heldLifecycle("captured native start requires matching permitted metadata/index lifecycle");
7592
+ if (sourceStatus === "start-failed-cleanup-required") {
7593
+ if (!latestSession || options.retryExecutionId !== latestSession.executionId) heldLifecycle("native cleanup requires its exact saved retry, not a new request");
7594
+ if (latestSession.state === "completed") heldLifecycle("completed native dispatch still has publication debt; explicit reconciliation is required");
7595
+ }
7596
+ let ownedState = sourceStatus;
7597
+ const assertRoots = () => {
7598
+ const row = assertCapturedInstanceCustody(original);
7599
+ if (row.status !== ownedState) heldLifecycle("native start no longer owns lifecycle state", row.status);
7600
+ const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
7601
+ for (const [key, path] of Object.entries({ historyRoot: dirname(history), history, retirementRoot: dirname(dirname(baseline)), baselines: dirname(baseline) })) {
7602
+ if (!lstatSync(path).isDirectory() || canonicalJson(directoryIdentity(path)) !== canonicalJson(original.nativeScaffold.directories[key])) throw oatsError("integrity-drift", "native custody directory was replaced");
7603
+ }
7604
+ // The strict Pi record owner validates its private manifest via the pinned
7605
+ // inspect/prepare/started APIs. Its approved expected-ID claim legitimately
7606
+ // advances v1 -> v2 INSIDE prepare's assertAuthority callbacks. This callback
7607
+ // guards kernel custody, not a duplicate/reentrant record codec. Freezing
7608
+ // record-owned bytes to v1 here makes every valid Pi claim fail mid-write.
7609
+ // Literal legacy manifest custody stays unchanged for other runtimes.
7610
+ if (!piHost) {
7611
+ const manifest = parseStrictJson(readPortableBytes(join(history, "history.json")));
7612
+ if (canonicalJson(manifest) !== canonicalJson({ version: 1, home, completeHistory: true })) throw oatsError("integrity-drift", "native history scaffold authority changed");
7613
+ }
7614
+ const current = readCapturedInstanceMetadata(home);
7615
+ assertCapturedSessionPlacement(current, backend, knownTargets);
7616
+ const saved = parseStrictJson(readPortableBytes(baseline)), authority = runtimeAuthorityOf(saved);
7617
+ if (saved.version !== RETIRE_BASELINE_VERSION || saved.home !== home || saved.incarnationId !== original.incarnationId
7618
+ || canonicalJson(saved.executionBinding) !== canonicalJson(binding) || !authority) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline differs from captured incarnation");
7619
+ if (authority.launched) {
7620
+ const target = authority.sessionTarget ?? { backend: "tmux", ...authority.tmux };
7621
+ validateCapturedSessionTarget(target, backend, current.instance);
7622
+ if (backend.backend === "herdr" && !knownTargets.some(known => canonicalJson(known) === canonicalJson(target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline target lacks indexed custody");
7623
+ } else if (current.launched || sessionHistory.some(intent => intent.state === "completed" && intent.receipt?.target)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native launch history contradicts an unlaunched baseline");
7624
+ if (current.launched !== authority.launched && !existsSync(join(home, ".oats-start-pending.json"))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native launch-state mismatch lacks pending recovery custody");
7625
+ return row;
7626
+ };
7627
+ const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
7628
+ const record = loaded.record, expectedAgent = record.subject.kind === "persistent" ? record.subject.soul.alias : record.subject.name;
7629
+ const human = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
7630
+ if (metadata.agent !== expectedAgent || metadata.kind !== record.subject.kind || canonicalJson(metadata.responsibleHuman) !== canonicalJson(human)) throw oatsError("invalid-resolution", "native target differs from captured subject/human");
7631
+ if (record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured native start requires supported owned directory work");
7632
+ if (loaded.approvals.some(item => !["approved", "not-required"].includes(item.status))) throw oatsError("approval-required", "captured native start needs every exact executable approval");
7633
+ if (!record.dispatch.launch) throw oatsError("needs-configuration", "captured record has no explicit launch inputs");
7634
+ if (record.dispatch.runtimePackages.length || applicableRequirements(record.dispatch.launch.runtime, [...loaded.capabilities.values()]).length) throw oatsError("needs-configuration", "required managed runtime roots/loader are not yet qualified; no current package discovery was used");
7635
+ if ([...loaded.capabilities.values()].some(cap => cap.manifest.hooks?.launch) || original.intents.some(intent => intent.action.kind === "hook" && intent.action.name === "spawn" && !intent.replayable)) throw oatsError("needs-configuration", "captured native hook contribution refresh is not qualified");
7636
+ const recipe = structuredClone(record.dispatch.launch);
7637
+ if (recipe.executableResource) {
7638
+ const resource = loaded.resources.get(recipe.executableResource);
7639
+ if (!resource) throw oatsError("resolution-incomplete", "captured native entrypoint resource is missing");
7640
+ const manifest = loaded.manifests.get(recipe.entrypoint.capability), specification = manifest?.commands?.[recipe.entrypoint.command];
7641
+ if (typeof specification !== "string") throw oatsError("invalid-resolution", "retained native entrypoint is not declared");
7642
+ const [script, ...declaredArgs] = specification.trim().split(/\s+/), declaredFile = capabilityExecutablePath(manifest, script);
7643
+ if (!declaredFile || realpathSync(declaredFile) !== resource) throw oatsError("invalid-resolution", "native entrypoint resource differs from retained command");
7644
+ if (declaredArgs.length) throw oatsError("needs-configuration", "native entrypoint arguments require a qualified captured adapter");
7645
+ recipe.executable = resource;
7646
+ } else if (recipe.executableResolvedFrom !== "explicit-host" || !isAbsolute(recipe.executable)) throw oatsError("needs-configuration", "native executable is neither a retained entrypoint nor an explicit host tool");
7647
+ if (!recipe.executableResource && existsSync(recipe.executable)) {
7648
+ const actual = realpathSync(recipe.executable), managed = join(deployment, ".agents");
7649
+ if (actual === managed || actual.startsWith(`${managed}${sep}`)) throw oatsError("needs-configuration", "an OATS-managed executable needs its retained resource reference, not a host-tool escape");
7650
+ }
7651
+ const broken = checkLaunchExecutable(recipe.executable);
7652
+ if (broken) throw oatsError("E_LAUNCH_EXECUTABLE", broken);
7653
+ const piHost = isPiSdkHost(recipe);
7654
+ if (recipe.runtime === "pi" && !piHost) throw oatsError("needs-configuration", "captured Pi requires the explicitly selected strict SDK host; a Pi CLI recipe was not reinterpreted");
7655
+ if (recipe.args.length && !piHost) throw oatsError("needs-configuration", "native extra arguments require a qualified captured adapter; no flags were inferred or ignored");
7656
+ const piProfile = piHost ? validatePiHostRecipe(recipe) : null; // refuse retained contributions before normalization
7657
+ recipe.hooks = { launch: {}, env: {}, contributions: [] };
7658
+ const piSessionDir = piHost ? capturedPiSessionDirectory(home) : null;
7659
+ if (piHost) {
7660
+ resolvePiSdkEntry(piProfile); // public export metadata only; no SDK/auth/model import
7661
+ requireCapturedPiRecordSupport(); // refuse before admission/backend when guards are absent
7662
+ inspectCapturedPiRoot(home, { incarnationId: original.incarnationId, sessionDir: piSessionDir });
7663
+ }
7664
+ const baseEnv = { ...(options.env || process.env) };
7665
+ for (const key of Object.keys(baseEnv)) if (/^(OATS_|OAS_|PI_AGENT_)/.test(key) || key === "PI_AGENTS_ROOT") delete baseEnv[key];
7666
+ const missing = missingLaunchEnvRefs(recipe.env, baseEnv);
7667
+ if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", "captured native environment references are unavailable");
7668
+ const backend = Object.hasOwn(options, "backend") ? options.backend : metadata.captured.nativeBackend;
7669
+ if (backend === undefined) throw oatsError("needs-configuration", "first captured native start needs an explicit backend endpoint");
7670
+ validateCapturedSessionBackend(backend);
7671
+ if (checkLaunchExecutable(backend.binary)) throw oatsError("E_LAUNCH_EXECUTABLE", "selected native backend executable is unavailable");
7672
+ if (Object.hasOwn(metadata.captured, "nativeBackend") && canonicalJson(metadata.captured.nativeBackend) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "native backend relocation requires separate qualified custody");
7673
+ const sessionHistory = original.intents.filter(intent => intent.action.kind === "session");
7674
+ const targetReceipts = sourceStatus === "start-failed-cleanup-required" ? sessionHistory.slice(-2) : sessionHistory.slice(-1);
7675
+ const knownTargets = backend.backend === "herdr" ? targetReceipts.flatMap(intent => intent.receipt?.target?.backend === "herdr"
7676
+ ? [validateCapturedSessionTarget(intent.receipt.target, backend, metadata.instance)] : []) : [];
7677
+ const nativeTarget = backend.backend === "tmux" ? { backend: "tmux", session: backend.session, window: metadata.instance, socket: backend.socket }
7678
+ : latestSession?.receipt?.target?.backend === "herdr" ? validateCapturedSessionTarget(latestSession.receipt.target, backend, metadata.instance) : undefined;
7679
+ assertCapturedSessionPlacement(metadata, backend, knownTargets);
7680
+ if (options.restart !== undefined && typeof options.restart !== "boolean") throw oatsError("invalid-declaration", "restart must be boolean");
7681
+ const taskFile = join(home, "TASK.md");
7682
+ if (options.task !== undefined && typeof options.task !== "string") throw oatsError("invalid-declaration", "native task must be explicit text");
7683
+ const task = options.task === undefined ? decodeUtf8(readPortableBytes(taskFile)) : options.task;
7684
+ canonicalJson(task, { maxBytes: 512 * 1024 });
7685
+ if (task.includes("\0")) throw oatsError("invalid-declaration", "native task contains NUL");
7686
+ if (piHost && !task.trim()) throw oatsError("E_PI_HOST_TASK", "captured Pi print requires a nonempty task");
7687
+ const taskBytes = Buffer.from(task);
7688
+ const assertCurriculum = () => {
7689
+ assertRoots();
7690
+ if (decodeUtf8(readPortableBytes(join(home, "AGENTS.md"))) !== loaded.composition.text
7691
+ || !lstatSync(join(home, "CLAUDE.md")).isSymbolicLink() || readlinkSync(join(home, "CLAUDE.md")) !== "AGENTS.md"
7692
+ || !lstatSync(join(home, "soul")).isSymbolicLink() || realpathSync(join(home, "soul")) !== dirname(loaded.resources.get(record.dispatch.composition.body))) throw oatsError("integrity-drift", "native curriculum differs from retained composition");
7693
+ const actual = readdirSync(join(home, ".agents/skills")).sort(), expected = loaded.composition.skills.map(skill => skill.name).sort();
7694
+ if (canonicalJson(actual) !== canonicalJson(expected)) throw oatsError("integrity-drift", "native skill inventory differs from retained composition");
7695
+ for (const skill of loaded.composition.skills) if (canonicalJson(treeIntegrity(join(home, ".agents/skills", skill.name))) !== canonicalJson(treeIntegrity(skill.path))) throw oatsError("integrity-drift", "native skill bytes differ from retained composition");
7696
+ };
7697
+ assertCurriculum();
7698
+ const action = { kind: "session", name: options.restart ? "restart" : "start" };
7699
+ const input = { backend, taskIntegrity: { format: "oats.bytes.v1", value: `sha256-${createHash("sha256").update(taskBytes).digest("hex")}` } };
7700
+ const pendingPath = join(home, ".oats-start-pending.json");
7701
+ const makeCommand = (intent) => `OATS_DEPLOYMENT=${shq(deployment)} OATS_RESOLUTION=${shq(resolution.id)} OATS_AGENT=${shq(metadata.agent)} OATS_CLI_BIN=${shq(realpathSync(join(PKG_ROOT, "bin/oats.mjs")))} OATS_INCARNATION_ID=${shq(original.incarnationId)} OATS_EXECUTION_ID=${shq(intent.executionId)} OATS_EXECUTION_ATTEMPT=${shq(String(intent.attempt))} ${renderLaunchRecipe(recipe, { home, instance: metadata.instance })}`;
7702
+ if (existsSync(pendingPath)) {
7703
+ const pending = parseStrictJson(readPortableBytes(pendingPath)), ref = pending.capturedIntent;
7704
+ validateIntentRef(ref);
7705
+ if (typeof pending.nativeRecordId !== "string" || !/^[a-f0-9-]{36}$/.test(pending.nativeRecordId)) throw oatsError("invalid-resolution", "pending native history reference is invalid");
7706
+ const evidence = parseStrictJson(readPortableBytes(join(nativeHistoryPath(home), `${pending.nativeRecordId}.json`)));
7707
+ if (evidence.version !== (piHost ? 2 : 1) || evidence.id !== pending.nativeRecordId || evidence.home !== home || evidence.runtime !== recipe.runtime || !(piHost ? ["pending", "root-established", "started"] : ["pending", "started"]).includes(evidence.state)) throw oatsError("invalid-resolution", "pending native history differs from captured start");
7708
+ if (piHost && (evidence.incarnationId !== original.incarnationId || canonicalJson(evidence.intent) !== canonicalJson(ref))) throw oatsError("invalid-resolution", "Pi native witness differs from the original admitted intent");
7709
+ const prior = original.intents.find(intent => intent.executionId === ref?.executionId);
7710
+ if (!prior || prior.action.kind !== "session" || latestSession.executionId !== pending.id || ref.incarnationId !== original.incarnationId || pending.id !== ref.executionId || ref.attempt > prior.attempt
7711
+ || canonicalJson(pending.launch) !== canonicalJson(recipe) || pending.command !== makeCommand(ref)) throw oatsError("invalid-resolution", "pending native receipt is not this captured launch");
7712
+ if (pending.phase === "allocating") {
7713
+ if (backend.backend !== "herdr" || canonicalJson(pending.endpoint) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "pending allocation endpoint differs from admitted backend");
7714
+ const error = oatsError("E_SESSION_UNKNOWN", "native allocation outcome is unknown; retain the same intent and reconcile, never allocate again");
7715
+ error.home = home; error.nativeCustody = { intent: ref, pendingPath, observed: prior.receipt, unconfirmed: true }; throw error;
7716
+ }
7717
+ validateCapturedSessionTarget(pending.target, backend, metadata.instance);
7718
+ if (backend.backend === "herdr" && (!prior.receipt?.target || canonicalJson(prior.receipt.target) !== canonicalJson(pending.target))) throw oatsError("invalid-resolution", "pending Herdr target lacks matching indexed observation");
7719
+ }
7720
+ // Static target/curriculum/backend/task validation precedes provider code.
7721
+ // This read-only inspection is not a provider-native enrollment grant.
7722
+ for (const [slot, providerBinding] of Object.entries(record.bindings)) {
7723
+ const capability = loaded.capabilities.get(providerBinding.capability), inspection = { kind: "inspect" };
7724
+ const invocation = buildCapturedInvocationContext({ loaded: { ...loaded, capability }, action: inspection,
7725
+ instance: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent } });
7726
+ const checked = runCapturedProviderBinding({ deployment, artifacts: record.artifacts, capability: capability.id, phase: "check", settings: capability.settings,
7727
+ input: { binding: providerBinding, context: record.context, action: inspection, invocation } });
7728
+ if (checked.status !== "ready") throw oatsError(checked.problems[0]?.code || "provider-not-qualified", `captured ${slot} provider is unavailable`);
7729
+ }
7730
+ assertCurriculum();
7731
+ const admission = admitCapturedInstanceAction({ deployment, home, executionBinding: binding, capability: null, action, input, expectedStatus: sourceStatus,
7732
+ ...(options.retryExecutionId !== undefined ? { retryExecutionId: options.retryExecutionId } : {}) });
7733
+ if (admission.replayed) return { ...admission.receipt, intent: admission.intent, replayed: true };
7734
+ let observed = null, pendingObservation = null;
7735
+ try {
7736
+ ownedState = "start-running";
7737
+ setCapturedInstanceStatus(deployment, home, ownedState, { expectedStatus: sourceStatus });
7738
+ beginCapturedIntent({ deployment, home, intent: admission.intent, action });
7739
+ atomicWriteFileSync(taskFile, task, { assertRoots });
7740
+ metadata = { ...metadata, backend: backend.backend,
7741
+ ...(backend.backend === "tmux" ? { tmux: { session: nativeTarget.session, window: nativeTarget.window, socket: nativeTarget.socket } } : {}),
7742
+ captured: { ...metadata.captured, nativeBackend: backend } };
7743
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson(metadata), { assertRoots });
7744
+ const plan = { recipe, command: makeCommand(admission.intent), runtime: recipe.runtime, model: recipe.model, yolo: recipe.yolo };
7745
+ const exec = (binary, args, executionOptions) => {
7746
+ assertCurriculum();
7747
+ if (!readPortableBytes(taskFile).equals(taskBytes)) throw oatsError("integrity-drift", "native task changed after admission");
7748
+ if (existsSync(pendingPath)) {
7749
+ const pending = parseStrictJson(readPortableBytes(pendingPath));
7750
+ if (pending.id === admission.intent.executionId) pendingObservation = { id: pending.id, startedAt: pending.startedAt,
7751
+ ...(pending.target ? { target: pending.target } : {}), ...(pending.endpoint ? { endpoint: pending.endpoint } : {}), ...(pending.phase ? { phase: pending.phase } : {}),
7752
+ ...(pending.nativeRecordId ? { nativeRecordId: pending.nativeRecordId } : {}), unconfirmed: true };
7753
+ }
7754
+ const env = { ...baseEnv };
7755
+ if (backend.backend === "herdr") {
7756
+ delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
7757
+ if (binary === backend.binary) {
7758
+ if (executionOptions.env?.HERDR_SOCKET_PATH !== backend.socket) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "Herdr transport socket differs from admitted endpoint");
7759
+ env.HERDR_SOCKET_PATH = backend.socket;
7760
+ }
7761
+ }
7762
+ return (options.io?.exec || execFileSync)(backend.backend === "tmux" && binary === "tmux" ? backend.binary : binary, args, { ...executionOptions, env });
7763
+ };
7764
+ const observeTarget = (target, facts) => {
7765
+ validateCapturedSessionTarget(target, backend, metadata.instance);
7766
+ pendingObservation = { ...facts, target, unconfirmed: true };
7767
+ knownTargets.push(target); assertRoots();
7768
+ observeCapturedNativeTarget(original, admission.intent, pendingObservation);
7769
+ };
7770
+ observed = startInstanceSession(home, { restart: !!options.restart, ...(options.stopGraceMs === undefined ? {} : { stopGraceMs: options.stopGraceMs }), env: baseEnv,
7771
+ io: { ...options.io, exec, strictHerdrTarget: backend.backend === "herdr" },
7772
+ [CAPTURED_NATIVE_START]: { plan, intent: admission.intent, endpoint: backend, target: nativeTarget, observeTarget, assertRoots,
7773
+ ...(piHost ? { prepareNativeRecord: () => {
7774
+ assertCurriculum();
7775
+ return prepareCapturedPiStart(home, { incarnationId: original.incarnationId, intent: admission.intent, sessionDir: piSessionDir, assertAuthority: assertRoots });
7776
+ } } : {}) } });
7777
+ assertRoots();
7778
+ settleCapturedIntent({ deployment, home, intent: admission.intent, action, state: "completed", receipt: observed, replayable: true });
7779
+ const next = readCapturedInstanceMetadata(home);
7780
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...next, captured: { ...next.captured, lifecycle: "start-dispatched", nativeIntent: admission.intent } }), { assertRoots });
7781
+ setCapturedInstanceStatus(deployment, home, "start-dispatched", { expectedStatus: ownedState });
7782
+ ownedState = "start-dispatched";
7783
+ return { ...observed, intent: admission.intent, incarnationId: original.incarnationId, dispatchAccepted: true };
7784
+ } catch (error) {
7785
+ let reportingFailure;
7786
+ try { failCapturedNativeIntent(original, admission.intent, observed ?? pendingObservation ?? admission.receipt); ownedState = "start-failed-cleanup-required"; }
7787
+ catch (failure) { reportingFailure = failure.code || "E_NATIVE_CUSTODY"; }
7788
+ try {
7789
+ assertRoots(); const current = readCapturedInstanceMetadata(home);
7790
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...current, captured: { ...current.captured, lifecycle: "start-failed-cleanup-required", nativeIntent: admission.intent } }), { assertRoots });
7791
+ } catch { /* never write/clean a replacement home */ }
7792
+ error.home = home; error.nativeCustody = { intent: admission.intent, pendingPath, observed, pendingObservation, unconfirmed: true, ...(reportingFailure ? { reportingFailure } : {}) };
7793
+ throw error;
7794
+ }
7795
+ }
7796
+
7434
7797
  export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
7435
7798
  export function startInstanceSession(home, o = {}) {
7436
7799
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
7437
- // Check the sibling receipt BEFORE resolving a possibly substituted home.
7438
7800
  if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
7439
- const checkRoots = sessionDirectoryGuard(home);
7801
+ const directoryGuard = sessionDirectoryGuard(home);
7802
+ if (!o[CAPTURED_NATIVE_START]) {
7803
+ let metadata; try { metadata = parseStrictJson(readPortableBytes(join(home, "instance.json"))); } catch { /* legacy receipt validation reports unreadable metadata */ }
7804
+ if (metadata?.executionBinding) {
7805
+ const portableOptions = { ...o };
7806
+ for (const key of ["model", "runtime", "launchConfig", "yolo"]) {
7807
+ if (portableOptions[key] !== undefined) throw oatsError("E_BAD_ARGS", "captured start cannot replace its recorded runtime/model/configuration");
7808
+ delete portableOptions[key]; // Legacy callers pass omitted options as undefined.
7809
+ }
7810
+ return startCapturedInstanceSession(home, portableOptions);
7811
+ }
7812
+ }
7813
+ const capturedStart = o[CAPTURED_NATIVE_START];
7814
+ const checkRoots = () => { directoryGuard(); capturedStart?.assertRoots(); };
7440
7815
  const realHome = realPathOrNearest(home);
7441
7816
  let originalHomeIdentity;
7442
7817
  try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
@@ -7460,7 +7835,7 @@ export function startInstanceSession(home, o = {}) {
7460
7835
  // The independent receipt first (retire and session consult it), then the
7461
7836
  // mutable metadata; both tmp+rename. A failure between them is what the
7462
7837
  // pending receipt exists for.
7463
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
7838
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
7464
7839
  checkRoots();
7465
7840
  const baselinePath = retirementBaselinePath(realHome);
7466
7841
  let baseline;
@@ -7478,9 +7853,10 @@ export function startInstanceSession(home, o = {}) {
7478
7853
  ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
7479
7854
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
7480
7855
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
7481
- writeJsonAtomic(metaPath, next);
7482
- if (clearPending) rmSync(pendingPath, { force: true });
7483
- return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: next.runtime, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, ...(stop ? { stop } : {}) };
7856
+ if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
7857
+ else writeJsonAtomic(metaPath, next);
7858
+ if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
7859
+ return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: next.runtime, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
7484
7860
  };
7485
7861
  try { mkdirSync(lock); }
7486
7862
  catch (e) {
@@ -7526,11 +7902,15 @@ export function startInstanceSession(home, o = {}) {
7526
7902
  const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
7527
7903
  if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
7528
7904
  }
7905
+ if (capturedStart && pending.id === capturedStart.intent.executionId && (!st.present || st.state === "shell")) throw oatsError("E_SESSION_UNKNOWN", "captured retry cannot turn a missing/exited pending target into a duplicate launch; retain and reconcile its evidence");
7529
7906
  // Reconcile even an exited target: the independent baseline may
7530
7907
  // already name it while metadata still names the old allocation.
7531
7908
  const meta = readMeta();
7532
7909
  const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
7533
7910
  if (st.present && st.state !== "shell") {
7911
+ // Same logical captured request already dispatched: adopting it is the
7912
+ // retry outcome, even for restart. Only a DISTINCT restart may continue.
7913
+ if (capturedStart && pending.id === capturedStart.intent.executionId) return done;
7534
7914
  if (o.restart) { rmSync(pendingPath, { force: true }); }
7535
7915
  else {
7536
7916
  // The recovered target runs what the receipt says; a choice made
@@ -7545,9 +7925,10 @@ export function startInstanceSession(home, o = {}) {
7545
7925
  // 2. The ordinary gate and observation, all under the lock.
7546
7926
  const receipt = instanceSessionTarget(realHome);
7547
7927
  const meta = readMeta();
7548
- const runtime = meta.runtime;
7928
+ const runtime = capturedStart?.plan.runtime || meta.runtime;
7549
7929
  if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
7550
- const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
7930
+ const backend = capturedStart ? capturedStart.endpoint.backend : (meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux");
7931
+ if (capturedStart && receipt.target && (!capturedStart.target || canonicalJson(receipt.target) !== canonicalJson(capturedStart.target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "independent endpoint differs from admitted native target");
7551
7932
  let command = meta.command;
7552
7933
  let model = meta.model || undefined;
7553
7934
  // What this start launches: the frozen command (optionally with another
@@ -7556,8 +7937,9 @@ export function startInstanceSession(home, o = {}) {
7556
7937
  // preflight happens here, before anything is observed or stopped.
7557
7938
  const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
7558
7939
  const hasRecipe = meta.launch && typeof meta.launch === "object";
7559
- let launchPlan = null;
7560
- if (selected || hasRecipe) {
7940
+ let launchPlan = capturedStart?.plan || null;
7941
+ if (capturedStart) { command = launchPlan.command; model = launchPlan.model; }
7942
+ else if (selected || hasRecipe) {
7561
7943
  // Every start of a home with a recipe (ordinary, model-only, or under a
7562
7944
  // selection) goes through the one planner: recipe shape, the recorded
7563
7945
  // or selected executable, references, capability contributions under
@@ -7583,7 +7965,8 @@ export function startInstanceSession(home, o = {}) {
7583
7965
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
7584
7966
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
7585
7967
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
7586
- const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
7968
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
7969
+ ...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
7587
7970
  let target = receipt.target;
7588
7971
  let state = { present: false, state: "not-launched" };
7589
7972
  let serverGone = false;
@@ -7607,28 +7990,40 @@ export function startInstanceSession(home, o = {}) {
7607
7990
  if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
7608
7991
  }
7609
7992
  const startedAt = new Date().toISOString();
7610
- const id = randomUUID();
7993
+ const id = capturedStart?.intent.executionId || randomUUID();
7611
7994
  checkRoots();
7612
- const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
7613
- const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7995
+ const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime,
7996
+ capturedStart ? id => { planExtra.nativeRecordId = id; } : undefined, capturedStart?.prepareNativeRecord);
7997
+ const completedCommand = capturedStart && isPiSdkHost(launchPlan?.recipe)
7998
+ ? renderCapturedPiCompletion(command, executionCommand, { home: realHome, nativeRecordId: planExtra.nativeRecordId })
7999
+ : `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7614
8000
  let reused = "new";
7615
8001
  if (backend === "herdr") {
7616
- if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
8002
+ if (!target && !capturedStart) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
8003
+ const base = capturedStart?.endpoint ?? { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
7617
8004
  if (state.present) { reused = "pane"; }
7618
8005
  else {
7619
- const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
7620
8006
  try { herdrSnapshot(base, o.io); }
7621
- catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
7622
- target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
8007
+ catch (e) {
8008
+ if (capturedStart) throw oatsError("E_SESSION_UNKNOWN", "selected Herdr endpoint is unavailable or incompatible; no fallback was started");
8009
+ throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`);
8010
+ }
8011
+ if (capturedStart) {
8012
+ checkRoots();
8013
+ writeJsonAtomic(pendingPath, { id, phase: "allocating", endpoint: base, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
8014
+ }
8015
+ try { target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io); }
8016
+ catch (e) { if (capturedStart) throw launchFailure("Herdr allocation", e); throw e; }
7623
8017
  }
8018
+ if (capturedStart) capturedStart.observeTarget(target, { id, startedAt, nativeRecordId: planExtra.nativeRecordId });
7624
8019
  checkRoots();
7625
8020
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7626
8021
  try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
7627
8022
  catch (e) { throw launchFailure("Herdr", e); }
7628
8023
  } else {
7629
- const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
7630
- const window = target?.window || meta.tmux?.window || meta.instance;
7631
- let socket = target?.socket || meta.tmux?.socket;
8024
+ const session = capturedStart?.target.session ?? (target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION);
8025
+ const window = capturedStart?.target.window ?? (target?.window || meta.tmux?.window || meta.instance);
8026
+ let socket = capturedStart?.target.socket ?? (target?.socket || meta.tmux?.socket);
7632
8027
  const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
7633
8028
  // A fallback shell (no harness descendant) or a retained dead pane is
7634
8029
  // the agent's own pane: the command runs there, no other window touched.