@awebai/oats 0.23.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +7 -4
  2. package/bin/oats-pi-sdk-host.mjs +17 -0
  3. package/bin/oats.mjs +442 -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 +101 -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/capability-manifest.schema.json +37 -66
  25. package/docs/captured-invocation-context.schema.json +7 -0
  26. package/docs/captured-resolution.schema.json +7 -0
  27. package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
  28. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
  29. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
  30. package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
  31. package/docs/design/2026-09-15-captured-dispatch.md +127 -0
  32. package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
  33. package/docs/design/2026-09-15-package-preparation.md +100 -0
  34. package/docs/design/2026-09-15-portable-data-contract.md +121 -0
  35. package/docs/design/2026-09-15-portable-declarations.md +189 -0
  36. package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
  37. package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
  38. package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
  39. package/docs/design/2026-09-15-source-observation.md +119 -0
  40. package/docs/design/2026-09-16-captured-admission.md +77 -0
  41. package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
  42. package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
  43. package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
  44. package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
  45. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
  46. package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
  47. package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
  48. package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
  49. package/docs/design/2026-09-16-portable-onboarding.md +177 -0
  50. package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
  51. package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
  52. package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
  53. package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
  54. package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
  55. package/docs/design/2026-09-17-captured-native-start.md +58 -0
  56. package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
  57. package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
  58. package/docs/design/2026-09-17-public-captured-start.md +108 -0
  59. package/docs/design/2026-09-17-public-prepare-request.md +90 -0
  60. package/docs/design/2026-09-18-captured-pi-host.md +205 -0
  61. package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
  62. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
  63. package/docs/desktop-cli-api.md +5 -2
  64. package/docs/execution-capsule.schema.json +108 -0
  65. package/docs/execution-targets.md +20 -7
  66. package/docs/oats-lock-v3.schema.json +7 -0
  67. package/docs/oats-member.schema.json +38 -0
  68. package/docs/oats-workspace.schema.json +68 -0
  69. package/docs/portable.schema.json +2512 -0
  70. package/docs/provider-check-input.schema.json +7 -0
  71. package/docs/release-notes/v0.23.2.md +49 -0
  72. package/docs/release-notes/v0.24.0.md +104 -0
  73. package/docs/schedules.md +126 -14
  74. package/docs/soul.schema.json +82 -0
  75. package/injects/oats-portable.md +17 -0
  76. package/injects/portable-instance-boundary.md +39 -0
  77. package/injects/portable-work-directory.md +29 -0
  78. package/lib/artifact-approvals.mjs +120 -0
  79. package/lib/artifact-tree.mjs +141 -0
  80. package/lib/capability-artifacts.mjs +179 -0
  81. package/lib/capability-execution.mjs +15 -0
  82. package/lib/capability-inputs.mjs +39 -0
  83. package/lib/capability-provenance.mjs +231 -0
  84. package/lib/captured-action-shape.mjs +21 -0
  85. package/lib/captured-admission-shape.mjs +20 -0
  86. package/lib/captured-binding-file.mjs +36 -0
  87. package/lib/captured-dispatch.mjs +66 -0
  88. package/lib/captured-instance-index.mjs +277 -0
  89. package/lib/captured-invocation-context.mjs +130 -0
  90. package/lib/captured-launch-request.mjs +46 -0
  91. package/lib/captured-operation-process.mjs +15 -0
  92. package/lib/captured-pi-custody.mjs +29 -0
  93. package/lib/captured-pi-host.mjs +167 -0
  94. package/lib/captured-pi-outcome.mjs +172 -0
  95. package/lib/captured-resolutions.mjs +275 -0
  96. package/lib/captured-scaffold.mjs +87 -0
  97. package/lib/captured-selector.mjs +28 -0
  98. package/lib/captured-session-backend.mjs +52 -0
  99. package/lib/captured-source-receipt-file.mjs +72 -0
  100. package/lib/config-data.mjs +104 -0
  101. package/lib/core.mjs +918 -562
  102. package/lib/errors.mjs +7 -0
  103. package/lib/helper-injection-policy.mjs +98 -0
  104. package/lib/herdr.mjs +18 -7
  105. package/lib/instruction-composition.mjs +31 -0
  106. package/lib/legacy-lock-codec.mjs +106 -0
  107. package/lib/manifest-settings.mjs +84 -0
  108. package/lib/package-closure.mjs +48 -0
  109. package/lib/package-materialization.mjs +83 -0
  110. package/lib/pi-sdk-host.mjs +229 -0
  111. package/lib/portable-artifacts.mjs +115 -0
  112. package/lib/portable-choices.mjs +82 -0
  113. package/lib/portable-composition.mjs +136 -0
  114. package/lib/portable-digest.mjs +105 -0
  115. package/lib/portable-files.mjs +26 -0
  116. package/lib/portable-identity.mjs +40 -0
  117. package/lib/portable-lock.mjs +117 -0
  118. package/lib/portable-migration-artifacts.mjs +135 -0
  119. package/lib/portable-migration-evidence.mjs +305 -0
  120. package/lib/portable-migration-store.mjs +199 -0
  121. package/lib/portable-migration.mjs +104 -0
  122. package/lib/portable-onboarding-acceptance.mjs +66 -0
  123. package/lib/portable-onboarding-request.mjs +49 -0
  124. package/lib/portable-onboarding.mjs +230 -0
  125. package/lib/portable-package-preparation.mjs +188 -0
  126. package/lib/portable-policy.mjs +44 -0
  127. package/lib/portable-shape.mjs +35 -0
  128. package/lib/portable-soul.mjs +38 -0
  129. package/lib/portable-state.mjs +80 -0
  130. package/lib/portable-values.mjs +181 -0
  131. package/lib/prepare-composition.mjs +151 -0
  132. package/lib/prepared-bindings.mjs +78 -0
  133. package/lib/prepared-resources.mjs +127 -0
  134. package/lib/provider-binding-broker.mjs +59 -0
  135. package/lib/provider-binding-wire.mjs +110 -0
  136. package/lib/provider-binding.mjs +22 -0
  137. package/lib/repository-observation.mjs +226 -0
  138. package/lib/resolution-shape.mjs +393 -0
  139. package/lib/schedule-capsule.mjs +206 -0
  140. package/lib/schedule.mjs +259 -38
  141. package/lib/servers.mjs +15 -0
  142. package/lib/soul-constraints.mjs +40 -0
  143. package/lib/source-projection.mjs +84 -0
  144. package/lib/source-spec.mjs +189 -0
  145. package/lib/workspace-definition.mjs +126 -0
  146. package/lib/workspace-discovery.mjs +146 -0
  147. package/package-catalog.json +1 -1
  148. package/package.json +3 -2
  149. package/packages/record/lib/capture-cc.mjs +14 -6
  150. package/packages/record/lib/formats.mjs +14 -3
  151. package/packages/record/lib/native-history.mjs +277 -7
  152. package/packages/record/lib/session-snapshot.mjs +25 -5
  153. package/packages/record/lib/sessions-for-home.mjs +30 -13
  154. package/skills/oats/SKILL.md +12 -7
  155. package/skills/oats-config/SKILL.md +12 -9
  156. package/skills/oats-packages/SKILL.md +12 -8
  157. package/skills/oats-portable/SKILL.md +116 -0
  158. package/skills/oats-portable-artifacts/SKILL.md +63 -0
  159. package/skills/oats-portable-setup/SKILL.md +69 -0
package/lib/core.mjs CHANGED
@@ -35,11 +35,65 @@ 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 { readLock3 } from "./portable-lock.mjs";
65
+ import { portableScope, portableStateDirectory } from "./portable-state.mjs";
66
+ import { objectAt } from "./portable-shape.mjs";
67
+ import { validateBindingInterface } from "./provider-binding.mjs";
68
+ import { invokeProviderBinding } from "./provider-binding-broker.mjs";
69
+ import { withCapturedBindingFile } from "./captured-binding-file.mjs";
70
+ import { validateCapturedSourceReceipt, withCapturedSourceReceiptFile } from "./captured-source-receipt-file.mjs";
71
+ export { withCapturedBindingFile, validateCapturedSourceReceipt, withCapturedSourceReceiptFile };
72
+ import { executableSurfaceOf, hasExecutableSurface } from "./capability-execution.mjs";
73
+ import { createCapabilityMaterializer } from "./package-materialization.mjs";
74
+ import { resolvePackageClosure } from "./package-closure.mjs";
75
+ import { canonicalJson, parseStrictJson, decodeUtf8 } from "./portable-values.mjs";
76
+ import { treeIntegrity } from "./portable-digest.mjs";
77
+ import { readPortableBytes } from "./portable-files.mjs";
78
+ import { capabilityArtifactIntegrity, copyTreeSafe, removeOwnedStaging } from "./artifact-tree.mjs";
79
+ import {
80
+ CAPABILITIES_DIRNAME, INSTALLED_SUBDIR,
81
+ isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
82
+ normalizePackagePath, parseLockSource, PACKAGE_ID_RE,
83
+ validateLockEntry, validateCapabilityLockEntry, verifyCapabilityInstallation,
84
+ } from "./capability-provenance.mjs";
85
+ import { decodeLegacyLockBytes, legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
86
+ // Keep the existing public core surface; internal data helpers are not re-exported.
87
+ export { oatsError } from "./errors.mjs";
88
+ export { legacyCapabilityEntryViolation } from "./legacy-lock-codec.mjs";
89
+ export { capabilityArtifactIntegrity, copyTreeSafe } from "./artifact-tree.mjs";
90
+ export {
91
+ CAPABILITIES_DIRNAME, INSTALLED_SUBDIR, CAPABILITY_ID_RE,
92
+ isMaterializedCapabilityId, capabilityIdViolation, CAPABILITY_INSTALLATION_FILE,
93
+ normalizePackagePath, validateLockEntry, validateCapabilityLockEntry,
94
+ verifyCapabilityInstallation,
95
+ } from "./capability-provenance.mjs";
96
+
43
97
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
44
98
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
45
99
  * must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
@@ -711,16 +765,20 @@ function bindingObject(value) {
711
765
  function hookDeclaration(value) {
712
766
  if (typeof value === "string") return { command: value, required: false };
713
767
  if (value && typeof value === "object" && typeof value.command === "string") {
714
- return { command: value.command, required: value.required === true };
768
+ return { command: value.command, required: value.required === true, ...(Object.hasOwn(value, "inputs") ? { inputs: value.inputs } : {}) };
715
769
  }
716
770
  return undefined;
717
771
  }
772
+ function manifestHookDeclarations(manifest) {
773
+ return Object.fromEntries(Object.entries(manifest?.hooks || {}).flatMap(([event, value]) => {
774
+ const declaration = hookDeclaration(value);
775
+ return APPROVED_HOOKS.has(event) && declaration ? [[event, declaration.command]] : [];
776
+ }));
777
+ }
718
778
  function manifestHookCommands(manifest) {
719
779
  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+/);
780
+ for (const [ev, command] of Object.entries(manifestHookDeclarations(manifest))) {
781
+ const [script, ...args] = command.split(/\s+/);
724
782
  const abs = manifestPath(manifest, script);
725
783
  if (abs) out[ev] = ["node", shq(abs), ...args].join(" ");
726
784
  }
@@ -849,11 +907,7 @@ export function resolveCapabilities(contextDir, soulName) {
849
907
  if (![...enabledValues][0]) continue;
850
908
  // A declared value set is enforced for an ACTIVE capability: a misspelling
851
909
  // 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
- }
910
+ assertCapabilitySettingValues(manifest, settings);
857
911
  const compatibility = capabilityCompatibility(manifest);
858
912
  if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
859
913
  const trust = capabilityTrust(manifest, contextDir);
@@ -988,33 +1042,10 @@ const OATS_HOME_DIR = process.env.OATS_HOME_DIR || join(homedir(), ".oats");
988
1042
  /** Legacy pre-v0.8 laptop acquisition root — kept only so doctor can warn about it. */
989
1043
  export const LEGACY_HOME_CAPABILITIES_DIR = join(OATS_HOME_DIR, "capabilities");
990
1044
  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
1045
  export const OWNED_SUBDIR = "owned";
995
1046
  export const installedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, INSTALLED_SUBDIR);
996
1047
  export const ownedCapabilitiesDir = (level) => join(level, CAPABILITIES_DIRNAME, OWNED_SUBDIR);
997
1048
 
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
1049
  const PORTABLE_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
1019
1050
  const CAPABILITY_ENV_ID_RE = /^[a-z][a-z0-9]*\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
1020
1051
  const CORE_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
@@ -1108,17 +1139,18 @@ export function stripInternalAnnotations(parsed) {
1108
1139
  return out;
1109
1140
  }
1110
1141
 
1111
- function loadManifestAt(idir, origin) {
1142
+ function loadManifestAt(idir, origin, { strict = false } = {}) {
1112
1143
  const mf = join(idir, "oats.json");
1113
1144
  if (!existsSync(mf)) return undefined;
1114
1145
  let raw;
1115
- try { raw = JSON.parse(readFileSync(mf, "utf8")); }
1116
- catch (e) { throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
1146
+ try { raw = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
1147
+ catch (e) { if (strict) throw e; throw new Error(`invalid capability manifest JSON ${mf}: ${e.message}`); }
1117
1148
  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
1149
  // BEFORE any validation or annotation: the artifact declares capability DATA,
1119
1150
  // never kernel annotations about itself.
1120
1151
  const m = stripInternalAnnotations(raw);
1121
1152
  const id = validateCapabilityManifest(m, mf);
1153
+ validateCapabilityInputDeclarations(m);
1122
1154
  if (!m.version || !m.description) throw new Error(`capability ${id} manifest needs version and description`);
1123
1155
  const targetFields = ["global", "groups", "souls", "targets"].filter((key) => Object.prototype.hasOwnProperty.call(m, key));
1124
1156
  if (targetFields.length) throw new Error(`capability ${id} manifest cannot declare config-owned targets: ${targetFields.join(", ")}`);
@@ -1126,6 +1158,7 @@ function loadManifestAt(idir, origin) {
1126
1158
  if (m.command && !/^[a-z0-9][a-z0-9-]*$/.test(m.command)) throw new Error(`capability ${id} has invalid command namespace "${m.command}"`);
1127
1159
  for (const [hook, value] of Object.entries(m.hooks || {})) {
1128
1160
  if (!APPROVED_HOOKS.has(hook)) throw new Error(`capability ${id} declares unsupported hook "${hook}"`);
1161
+ if (value && typeof value === "object") objectAt(value, ["command", "required", "inputs"], ["command"]);
1129
1162
  if (!hookDeclaration(value)) throw new Error(`capability ${id} hook "${hook}" must be a command string or { command, required }`);
1130
1163
  if (value && typeof value === "object" && value.required !== undefined && typeof value.required !== "boolean") {
1131
1164
  throw new Error(`capability ${id} hook "${hook}": "required" must be a boolean`);
@@ -1136,9 +1169,322 @@ function loadManifestAt(idir, origin) {
1136
1169
  }
1137
1170
  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
1171
  validateManifestOperations(m, id);
1172
+ validateBindingInterface(m);
1139
1173
  return { ...m, _dir: idir, _origin: origin };
1140
1174
  }
1141
1175
 
1176
+ function assertCapabilitySettingValues(manifest, settings) {
1177
+ for (const [key, decl] of Object.entries(manifest.settings || {})) {
1178
+ 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]))) {
1179
+ throw new Error(`capability setting ${manifest.capability}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ function loadRetainedManifest(root) {
1185
+ const manifest = loadManifestAt(root, "captured", { strict: true });
1186
+ if (!manifest) throw oatsError("resolution-incomplete", "retained capability manifest is absent");
1187
+ assertCapabilitySelfContained(root, manifest);
1188
+ const compatible = capabilityCompatibility(manifest);
1189
+ if (!compatible.compatible) throw oatsError("capability-incompatible", `captured capability requires OATS ${compatible.range}`);
1190
+ return manifest;
1191
+ }
1192
+
1193
+ export function approveAvailableCapability(deployment, artifactSet, capability, origin) {
1194
+ return approveAvailableArtifact(deployment, artifactSet, capability, origin, loadRetainedManifest);
1195
+ }
1196
+
1197
+ /** Explicit new-work preparation, with one frozen repository transaction.
1198
+ * This first adapter captures command/curriculum profiles; provider-owned
1199
+ * binding or distinct helper policy gaps return needs-configuration, never
1200
+ * a fabricated complete record. No lifecycle side effects run here. */
1201
+ export function prepareCapturedComposition(input, { repositoryOptions = {} } = {}) {
1202
+ canonicalJson(input);
1203
+ objectAt(input, ["deployment", "source", "origin", "workspace", "member", "operator", "mode", "allowLocalPaths", "standaloneContextKey", "launch", "helperLaunches"], ["deployment", "source"]);
1204
+ if (input.launch !== undefined) validateCapturedLaunchRequest(input.launch, validateLaunchConfig);
1205
+ if (input.helperLaunches !== undefined) {
1206
+ objectAt(input.helperLaunches, null, []);
1207
+ for (const request of Object.values(input.helperLaunches)) validateCapturedLaunchRequest(request, validateLaunchConfig);
1208
+ }
1209
+ if (input.mode !== undefined && !WORK_MODES.includes(input.mode)) throw oatsError("invalid-declaration", "invalid preparation work mode");
1210
+ const deployment = portableScope(input.deployment);
1211
+ const previous = readLock3(deployment); // Old state refuses before scratch/fetch.
1212
+ const directory = mkdtempSync(join(portableStateDirectory(deployment, true), ".prepare-")), owned = lstatSync(directory);
1213
+ const origin = input.origin ?? { kind: "operator", document: { kind: "operator", id: "oats-prepare" }, pointer: "/source" };
1214
+ let repositories, primary, catalog;
1215
+ try {
1216
+ repositories = createRepositoryTransaction({ ...repositoryOptions, directory, accessContextKey: repositoryOptions.accessContextKey ?? "native" });
1217
+ return prepareComposition({ ...input, deployment, origin, directory }, { repositories, previous, kernel: {
1218
+ loadPackageManifestAt, capabilityCompatibility, assertPlatformInvariantLocks, materializeCapability,
1219
+ manifest: loadRetainedManifest,
1220
+ binding: runCapturedProviderBinding,
1221
+ catalog(id, selector) {
1222
+ catalog ??= officialPackageCatalog();
1223
+ if (!Object.hasOwn(catalog, id)) throw oatsError("invalid-source", "unknown package dependency catalog identity");
1224
+ return { ...catalog[id], ...(selector === undefined ? {} : { ref: selector }) };
1225
+ },
1226
+ complete: data => completePreparedResources(data, { root: PKG_ROOT, workModes: WORK_MODES,
1227
+ settings: assertCapabilitySettingValues, skills: skillEntriesIn, hooks: manifestHookDeclarations,
1228
+ validateLaunchConfig, runtimeRequirements: applicableRequirements }),
1229
+ } });
1230
+ } catch (error) { primary = error; throw error; }
1231
+ finally {
1232
+ try {
1233
+ repositories?.close();
1234
+ const current = lstatSync(directory);
1235
+ if (!current.isDirectory() || current.dev !== owned.dev || current.ino !== owned.ino) throw oatsError("source-incomplete", "preparation scratch ownership changed");
1236
+ removeOwnedStaging(directory);
1237
+ } catch (cleanup) {
1238
+ if (!primary) throw cleanup;
1239
+ const error = new AggregateError([primary, cleanup], "preparation and owned scratch cleanup failed", { cause: primary });
1240
+ error.code = primary.code; error.stagingPath = directory; throw error;
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ /** Load an exact record for one action, using the existing COMPLETE kernel
1246
+ * manifest codecs. No scoped config, mutable install or marketplace fallback.
1247
+ * Launch/lifecycle adoption and provider qualification are separate integration
1248
+ * steps; unsupported actions refuse rather than switching to legacy dispatch. */
1249
+ function capturedCapabilityCodecs() {
1250
+ return {
1251
+ manifest: loadRetainedManifest,
1252
+ settings: assertCapabilitySettingValues,
1253
+ host: ({ manifest }) => (manifest.requires || []).filter((requirement) => requirement.command && !which(requirement.command)),
1254
+ executable(manifest, relativePath) {
1255
+ const file = capabilityExecutablePath(manifest, relativePath);
1256
+ if (!file || !statSync(file).isFile()) throw oatsError("resource-not-found", "captured executable is not a regular file");
1257
+ return realpathSync(file);
1258
+ },
1259
+ };
1260
+ }
1261
+
1262
+ /** Exact prospective/captured binding phases; no partial record or ambient provider. */
1263
+ export function runCapturedProviderBinding(options) {
1264
+ return invokeProviderBinding(options, capturedCapabilityCodecs());
1265
+ }
1266
+
1267
+ function capturedDispatchCodecs(options, { admitting = false } = {}) {
1268
+ return {
1269
+ ...capturedCapabilityCodecs(),
1270
+ launch(recipe) {
1271
+ assertLaunchRecipe(recipe, "captured resolution");
1272
+ validateLaunchConfig("captured", { runtime: recipe.runtime, executable: recipe.executable, args: recipe.args, env: recipe.env,
1273
+ ...(recipe.model != null ? { model: recipe.model } : {}), ...(recipe.yolo !== undefined ? { yolo: recipe.yolo } : {}) }, "captured resolution");
1274
+ },
1275
+ operations: manifestOperations,
1276
+ hooks: manifestHookDeclarations,
1277
+ invocation(base, action, capability) {
1278
+ return buildCapturedInvocationContext({ loaded: { ...base, capability }, action,
1279
+ instance: options.invocationTarget ?? null, intent: options.intent ?? null,
1280
+ ...(Object.hasOwn(options, "priorReceipt") ? { priorReceipt: options.priorReceipt } : {}) });
1281
+ },
1282
+ bindings(bindings, capability, record, invocation) {
1283
+ // Admission is static authorization to ATTEMPT, not readiness or effects.
1284
+ // The normal loader still checks readiness with the saved intent before execution.
1285
+ if (admitting) return;
1286
+ for (const [slot, binding] of bindings) {
1287
+ if (capability.manifest.layer !== slot) throw oatsError("invalid-resolution", "captured binding slot differs from the manifest owner");
1288
+ const result = runCapturedProviderBinding({ deployment: options.deployment, artifacts: record.artifacts,
1289
+ capability: capability.id, phase: "check", settings: capability.settings,
1290
+ input: { binding, context: record.context, action: options.action, ...(invocation ? { invocation } : {}) } });
1291
+ if (result.status !== "ready") throw oatsError(result.problems[0]?.code ?? "provider-not-qualified", "captured provider is not ready for this action");
1292
+ }
1293
+ },
1294
+ };
1295
+ }
1296
+
1297
+ export function loadCapturedDispatch(options) {
1298
+ return loadCapturedAction(options, capturedDispatchCodecs(options));
1299
+ }
1300
+
1301
+ /** Admit against exact retained action/approval and current owned incarnation.
1302
+ * No provider phase runs here; readiness/execution consume the persisted intent. */
1303
+ export function admitCapturedAction({ deployment, resolution, home, action, input = {}, retryExecutionId, ...rest }) {
1304
+ canonicalJson(rest); objectAt(rest, ["priorReceipt"], []);
1305
+ const metadata = readCapturedInstanceMetadata(home);
1306
+ const options = { deployment, resolution, action,
1307
+ invocationTarget: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent },
1308
+ ...(Object.hasOwn(rest, "priorReceipt") ? { priorReceipt: rest.priorReceipt } : {}) };
1309
+ const loaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
1310
+ if (!loaded.capability) throw oatsError("unsupported-action", "inspection/composition does not admit mutation");
1311
+ return admitCapturedInstanceAction({ deployment, home, executionBinding: loaded.invocation.executionBinding,
1312
+ capability: loaded.capability.id, action, input, ...(retryExecutionId !== undefined ? { retryExecutionId } : {}) });
1313
+ }
1314
+
1315
+ /** Static callable API availability, not instance/provider/host readiness.
1316
+ * This descriptor never probes a backend or admits a native request. */
1317
+ export function capturedNativeSessionAvailability() {
1318
+ return { schemaVersion: 1, api: { contract: "oats.captured-session", version: 2, available: true, backends: ["tmux", "herdr"] }, readiness: { status: "not-checked" } };
1319
+ }
1320
+
1321
+ /** Resolve only a source record's explicit helper edge. This is static retained
1322
+ * authority inspection, never helper-name discovery, preparation or launch. */
1323
+ export function resolveCapturedHelper(input) {
1324
+ canonicalJson(input);
1325
+ objectAt(input, ["executionBinding", "helper", "name"], ["executionBinding", "helper"]);
1326
+ validateExecutionBinding(input.executionBinding);
1327
+ if (typeof input.helper !== "string" || !input.helper || input.helper.includes("\0")) throw oatsError("invalid-declaration", "helper must be an exact captured helper-map key");
1328
+ if (Object.hasOwn(input, "name") && (typeof input.name !== "string" || !input.name)) throw oatsError("invalid-declaration", "expected helper name must be non-empty text");
1329
+ const deployment = portableScope(input.executionBinding.deployment);
1330
+ const source = loadCapturedDispatch({ deployment, resolution: input.executionBinding.resolution, action: { kind: "inspect" } });
1331
+ if (!Object.hasOwn(source.record.helpers, input.helper)) throw oatsError("helper-not-selected", "source resolution does not select that helper key");
1332
+ const resolution = source.record.helpers[input.helper];
1333
+ const helper = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
1334
+ if (helper.record.subject.kind !== "helper") throw oatsError("invalid-resolution", "captured helper edge does not name a dedicated helper");
1335
+ if (input.name !== undefined && input.name !== helper.record.subject.name) throw oatsError("invalid-resolution", "captured helper name differs from requested name");
1336
+ const responsibleHuman = helper.record.messagingChoice.enabled ? helper.record.messagingChoice.privateKey.human : null;
1337
+ const sourceHuman = source.record.messagingChoice.enabled ? source.record.messagingChoice.privateKey.human : null;
1338
+ if (canonicalJson(source.record.context) !== canonicalJson(helper.record.context) || canonicalJson(sourceHuman) !== canonicalJson(responsibleHuman)) {
1339
+ throw oatsError("needs-configuration", "distinct helper context/human requires an explicit captured helper-request policy; no implicit inheritance was used");
1340
+ }
1341
+ if (!helper.record.dispatch.composition) throw oatsError("resolution-incomplete", "selected helper has no captured curriculum");
1342
+ return {
1343
+ schemaVersion: 1,
1344
+ sourceExecutionBinding: { schemaVersion: 1, deployment, resolution: source.resolution },
1345
+ executionBinding: { schemaVersion: 1, deployment, resolution },
1346
+ helper: { key: input.helper, name: helper.record.subject.name, subject: helper.record.subject },
1347
+ context: helper.record.context, responsibleHuman, workMode: helper.record.dispatch.composition.mode,
1348
+ launchSelection: helper.record.dispatch.launch === null ? null : {
1349
+ runtime: helper.record.dispatch.launch.runtime, model: helper.record.dispatch.launch.model,
1350
+ },
1351
+ launch: capturedNativeSessionAvailability(),
1352
+ };
1353
+ }
1354
+
1355
+ /** Bounded scaffold-only adoption of one exact captured composition. Placement
1356
+ * is explicit and launch/hooks remain pending; unsupported work modes refuse. */
1357
+ export function scaffoldCapturedInstance({ deployment, resolution, home, instance }) {
1358
+ const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
1359
+ const blocked = loaded.approvals.filter((entry) => !["approved", "not-required"].includes(entry.status));
1360
+ if (blocked.length) throw oatsError("approval-required", "captured scaffold needs approval for every selected executable artifact", blocked);
1361
+ validateCapturedDirectoryTarget(home, instance);
1362
+ if (loaded.record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured scaffold currently requires retained directory work mode");
1363
+ if (existsSync(home)) throw oatsError("E_INSTANCE_EXISTS", `instance home already exists: ${home}`);
1364
+ // Native evidence is initialized only for this newly created incarnation.
1365
+ // Existing sidecars are retained obligations, never silent history backfill.
1366
+ const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
1367
+ const parents = [dirname(history), dirname(dirname(baseline)), dirname(baseline)];
1368
+ const parentGuard = (create = false) => {
1369
+ for (const path of parents) {
1370
+ if (create && !existsSync(path)) mkdirSync(path, { mode: 0o700 });
1371
+ if (existsSync(path) && !lstatSync(path).isDirectory()) throw oatsError("integrity-drift", "native custody parent is not an owned directory");
1372
+ }
1373
+ };
1374
+ parentGuard();
1375
+ if (existsSync(history) || existsSync(baseline)) throw oatsError("migration-required", "native custody already exists at this home address; preserve it and select a fresh home");
1376
+ const result = materializeCapturedDirectoryScaffold({ home, instance, loaded });
1377
+ try {
1378
+ const original = readCapturedInstanceAuthority(deployment, home).row;
1379
+ const assertRoots = () => { assertCapturedInstanceCustody(original); parentGuard(); };
1380
+ assertRoots(); parentGuard(true); initializeNativeHistory(home);
1381
+ writeRetirementBaseline(home, join(home, "work"), "directory", {}, [], { launched: false },
1382
+ { exclusive: true, incarnationId: original.incarnationId, executionBinding: original.executionBinding });
1383
+ assertRoots();
1384
+ const metadata = readCapturedInstanceMetadata(home);
1385
+ const proof = { schemaVersion: 1, directories: { historyRoot: directoryIdentity(dirname(history)), history: directoryIdentity(history),
1386
+ retirementRoot: directoryIdentity(dirname(dirname(baseline))), baselines: directoryIdentity(dirname(baseline)) } };
1387
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...metadata, captured: { ...metadata.captured, nativeScaffold: proof } }), { assertRoots });
1388
+ markCapturedNativeScaffold(deployment, home, proof);
1389
+ return result;
1390
+ } catch (error) { error.home = home; error.cleanupRequired = true; throw error; }
1391
+ }
1392
+
1393
+ /** Run retained spawn hooks for an exact scaffold. Required-hook failure retains
1394
+ * the home and its receipts for explicit compensation; launch stays pending. */
1395
+ export function activateCapturedScaffold(options) {
1396
+ let { deployment, resolution, home, extraEnv = {} } = options;
1397
+ home = resolve(home);
1398
+ const file = join(home, "instance.json");
1399
+ let metadata = readCapturedInstanceMetadata(home);
1400
+ const original = readCapturedInstanceAuthority(deployment, home).row;
1401
+ const retry = Object.hasOwn(options, "retryIntents"), retryIntents = retry ? options.retryIntents : {};
1402
+ canonicalJson(retryIntents); objectAt(retryIntents, null, []);
1403
+ const savedIntents = {};
1404
+ for (const intent of original.intents.filter(entry => entry.action.kind === "hook" && entry.action.name === "spawn")) {
1405
+ if (Object.hasOwn(savedIntents, intent.capability)) throw oatsError("invalid-resolution", "multiple spawn requests need explicit reconciliation, not inferred retry selection");
1406
+ savedIntents[intent.capability] = { schemaVersion: 1, executionId: intent.executionId, incarnationId: original.incarnationId, attempt: intent.attempt };
1407
+ }
1408
+ for (const [id, intent] of Object.entries(metadata.captured.hookIntents || {})) {
1409
+ if (savedIntents[id]?.executionId !== intent.executionId || intent.incarnationId !== original.incarnationId) throw oatsError("invalid-resolution", "saved spawn reference differs from indexed authority");
1410
+ }
1411
+ if (retry) {
1412
+ const expected = Object.fromEntries(Object.entries(savedIntents).map(([id, intent]) => [id, intent.executionId]));
1413
+ if (canonicalJson(expected) !== canonicalJson(retryIntents)) throw oatsError("invalid-resolution", "spawn retry must name every indexed hook intent exactly; no partial implicit new requests");
1414
+ } else if (Object.keys(savedIntents).length) throw oatsError("invalid-resolution", "existing spawn obligations require an explicit retry");
1415
+ const priorStatus = retry ? metadata.captured.lifecycle : "scaffolded-hooks-pending";
1416
+ if (retry && !["spawn-failed-cleanup-required", "spawned-cleanup-required"].includes(priorStatus)) throw oatsError("invalid-resolution", "captured scaffold is not awaiting activation retry");
1417
+ const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
1418
+ const composed = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
1419
+ const subject = inspected.record.subject, expectedAgent = subject.kind === "persistent" ? subject.soul.alias : subject.name;
1420
+ const expectedHuman = inspected.record.messagingChoice.enabled ? inspected.record.messagingChoice.privateKey.human : null;
1421
+ let sameDeployment = false;
1422
+ try { sameDeployment = portableScope(metadata.executionBinding?.deployment) === portableScope(deployment); } catch { /* refused below */ }
1423
+ if (metadata.home !== home || metadata.instance !== basename(home) || metadata.agent !== expectedAgent
1424
+ || metadata.captured?.lifecycle !== priorStatus || metadata.executionBinding?.resolution?.id !== resolution.id
1425
+ || !sameDeployment || canonicalJson(metadata.responsibleHuman) !== canonicalJson(expectedHuman)) {
1426
+ throw oatsError("invalid-resolution", "captured scaffold metadata differs from the requested resolution or home");
1427
+ }
1428
+ const assertRoots = () => {
1429
+ const row = assertCapturedInstanceCustody(original);
1430
+ if (row.status !== "spawn-hooks-running") throw oatsError("selection-changed", "activation no longer owns the indexed lifecycle state");
1431
+ return row;
1432
+ };
1433
+ const reportFailure = (error, observed = {}) => {
1434
+ const facts = { meta: observed.meta || {}, intents: observed.intents || {} };
1435
+ let report, reportingFailure;
1436
+ try { report = recordCapturedCustodyFailure(original, { ...facts, code: error.code || "E_CAPTURED_PUBLICATION", message: error.message }); }
1437
+ catch (failure) { reportingFailure = { code: failure.code || "E_CAPTURED_CUSTODY", message: String(failure.message).slice(0, 200) }; }
1438
+ // Independent ledger first; if that too is held, propagate the observed
1439
+ // receipt through the existing caller error channel, not the foreign home.
1440
+ error.home = home;
1441
+ error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: original.executionBinding,
1442
+ ...facts, ...(report ? { report } : {}), ...(reportingFailure ? { reportingFailure } : {}) };
1443
+ throw error;
1444
+ };
1445
+ const publish = (next, status, observed = {}) => {
1446
+ try {
1447
+ atomicWriteFileSync(file, canonicalJson(next), { assertRoots });
1448
+ assertRoots();
1449
+ setCapturedInstanceStatus(deployment, home, status, { expectedStatus: "spawn-hooks-running" });
1450
+ } catch (error) { reportFailure(error, observed); }
1451
+ };
1452
+ setCapturedInstanceStatus(deployment, home, "spawn-hooks-running", { expectedStatus: priorStatus });
1453
+ let hooks;
1454
+ try {
1455
+ hooks = runCapturedLifecycleHooks("spawn", { deployment, resolution, home, instance: metadata.instance, agentName: expectedAgent, retryIntents, assertRoots,
1456
+ extraEnv: { ...extraEnv, OATS_WORK: "directory", OATS_KIND: subject.kind } });
1457
+ } catch (error) {
1458
+ const failure = { capability: "oats.kernel", event: "spawn", message: String(error.message || error).slice(0, 200), required: true, unconfirmed: true };
1459
+ metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...error.capturedHooks?.meta }, captured: { ...metadata.captured,
1460
+ lifecycle: "spawn-failed-cleanup-required", hookOrder: error.capturedHooks?.order || [], hookFailures: [failure],
1461
+ hookIntents: { ...savedIntents, ...error.capturedHooks?.intents } } };
1462
+ publish(metadata, "spawn-failed-cleanup-required", error.capturedHooks);
1463
+ error.home = home; throw error;
1464
+ }
1465
+ const required = hooks.failures.filter((failure) => failure.required);
1466
+ let unsettled;
1467
+ try {
1468
+ const row = readCapturedInstanceIndex(deployment).instances.find(row => row.incarnationId === original.incarnationId);
1469
+ if (!row) throw oatsError("selection-changed", "captured activation is no longer indexed");
1470
+ unsettled = row.intents.some(intent => intent.state !== "completed");
1471
+ } catch (error) { reportFailure(error, hooks); }
1472
+ // Optional functionality can fail without a required-hook error, but custody
1473
+ // remains explicitly nonterminal and the saved-map retry route stays usable.
1474
+ const status = required.length ? "spawn-failed-cleanup-required" : unsettled ? "spawned-cleanup-required" : "spawned-launch-pending";
1475
+ hooks.intents = { ...savedIntents, ...hooks.intents };
1476
+ metadata = { ...metadata, capabilityMeta: { ...metadata.capabilityMeta, ...hooks.meta }, captured: { ...metadata.captured,
1477
+ lifecycle: status, hookOrder: hooks.order, hookFailures: hooks.failures, hookIntents: hooks.intents } };
1478
+ publish(metadata, status, hooks);
1479
+ if (required.length) {
1480
+ const error = oatsError("E_REQUIRED_HOOK_FAILED", "captured spawn hook failed; home and receipts are retained for explicit cleanup", required);
1481
+ error.home = home; throw error;
1482
+ }
1483
+ return { home, instance: metadata.instance, agent: expectedAgent, incarnationId: metadata.incarnationId, executionBinding: metadata.executionBinding,
1484
+ responsibleHuman: expectedHuman, launched: false, hooks, launchPending: true, functionalReady: true,
1485
+ hooksPending: unsettled, cleanupRequired: unsettled };
1486
+ }
1487
+
1142
1488
  /** `operations`: what a capability offers a GUI or scheduler by name, each
1143
1489
  * delegating to one of its own `commands`. kind "action" runs something;
1144
1490
  * kind "view" answers { documents: [...] } for presentation (a knowledge
@@ -1251,40 +1597,6 @@ export function capabilityManifest(name, startDir) {
1251
1597
  return capabilityManifests(startDir)[name];
1252
1598
  }
1253
1599
 
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
1600
  /** Remove source-control metadata from the ROOT of a managed artifact.
1289
1601
  *
1290
1602
  * It must not be installed, and it must not become an integrity exclusion:
@@ -1588,14 +1900,6 @@ export function capabilityCompatibility(manifest, version = OATS_VERSION) {
1588
1900
  }
1589
1901
 
1590
1902
  // ---------- 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
1903
  /** Where one materialized capability artifact lives at a scope.
1600
1904
  *
1601
1905
  * This is the LAST line of defence, and it is a positive proof rather than a
@@ -1614,10 +1918,6 @@ export const installedCapabilityDir = (levelDir, capabilityId) => {
1614
1918
  }
1615
1919
  return dir;
1616
1920
  };
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
1921
  /** Prefix of a transaction staging directory. It lives inside the (gitignored)
1622
1922
  * installed store so the commit phase is a same-filesystem rename, and it is
1623
1923
  * dot-prefixed so discovery skips it. */
@@ -1628,37 +1928,6 @@ const STAGING_PREFIX = ".staging-";
1628
1928
  * use site: every resolver reads the configured path and falls back here. */
1629
1929
  export const DEFAULT_PACKAGE_PATH = "oats-package";
1630
1930
 
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
1931
  /** Split an optional `#<package-path>` fragment off a source spec. A source may
1663
1932
  * carry at most one fragment; the fragment is removed BEFORE `@ref` parsing so
1664
1933
  * a path can never be mistaken for part of a ref. */
@@ -1768,51 +2037,6 @@ export function inspectGitSourceRoot(spec) {
1768
2037
  } catch (e) { rmSync(tmp, { recursive: true, force: true }); throw e; }
1769
2038
  }
1770
2039
 
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
2040
  /** Official package catalog: identity + discovery ONLY — resolving through it
1817
2041
  * never advances a lock and never grants executable trust (contract §1).
1818
2042
  * Workstream 3 seeds the kernel-bundled catalog; OATS_PACKAGE_CATALOG points
@@ -1914,27 +2138,6 @@ export function packageIntegrity(dir) {
1914
2138
  return `sha256-${hash.digest("hex")}`;
1915
2139
  }
1916
2140
 
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
2141
  const PACKAGE_MANIFEST_KEYS = new Set(["package", "version", "description", "compatibility", "capabilities", "configTemplates", "configs", "dependencies"]);
1939
2142
  /** Canonical home of a package's config templates. Manifest paths are
1940
2143
  * repository-relative and always spelled with "/", on every platform. */
@@ -1959,12 +2162,12 @@ export function isCanonicalTemplatePath(p) {
1959
2162
  * whichever spelling the manifest used
1960
2163
  * _capabilities [{ id, rel, dir, manifest }]
1961
2164
  */
1962
- export function loadPackageManifestAt(pdir) {
2165
+ export function loadPackageManifestAt(pdir, { strict = false } = {}) {
1963
2166
  const mf = join(pdir, "oats-package.json");
1964
2167
  if (!existsSync(mf)) throw oatsError("invalid-package-manifest", `${pdir} has no oats-package.json distribution manifest`);
1965
2168
  let m;
1966
- try { m = JSON.parse(readFileSync(mf, "utf8")); }
1967
- catch (e) { throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
2169
+ try { m = strict ? parseStrictJson(readPortableBytes(mf)) : JSON.parse(readFileSync(mf, "utf8")); }
2170
+ catch (e) { if (strict) throw e; throw oatsError("invalid-package-manifest", `invalid JSON in ${mf}: ${e.message}`); }
1968
2171
  // Hostile-input shapes: JSON null/scalar/array roots are valid JSON but not manifests.
1969
2172
  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
2173
  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 +2218,7 @@ export function loadPackageManifestAt(pdir) {
2015
2218
  for (const rel of m.capabilities) {
2016
2219
  const dir = inside(rel, "capability");
2017
2220
  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}`);
2221
+ const cm = loadManifestAt(dir, `package:${m.package}`, { strict });
2019
2222
  // A PACKAGE-exported id will be materialized as a directory name under
2020
2223
  // installed/, so it must satisfy the materialized grammar — stricter than
2021
2224
  // the legacy standalone-capability rule loadManifestAt applies, which still
@@ -2129,21 +2332,7 @@ export function assertCapabilitySelfContained(capDir, manifest) {
2129
2332
  }
2130
2333
  }
2131
2334
 
2132
- const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
2133
2335
  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
2336
  /** A null-prototype copy of a raw parsed JSON map. Raw objects return inherited
2148
2337
  * `constructor`/`toString`/`valueOf` for `map[id]` even with no own entry, so
2149
2338
  * every ID-keyed map in the engine goes through this (or `Object.hasOwn`) before
@@ -2154,93 +2343,15 @@ function nullProtoMap(raw) {
2154
2343
  return out;
2155
2344
  }
2156
2345
 
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. */
2346
+ /** Public bytes seam for evidence-grade migration. Ordinary consumers use the
2347
+ * file wrapper below; both share the one acyclic legacy decoder. */
2348
+ export function parseLockBytesStrict(bytes, { file = "<oats-lock.json>", limits } = {}) {
2349
+ return decodeLegacyLockBytes(bytes, { file, limits, retiredCapabilityReason });
2350
+ }
2351
+
2166
2352
  export function parseLockFileStrict(file) {
2167
2353
  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;
2354
+ return parseLockBytesStrict(readFileSync(file), { file });
2244
2355
  }
2245
2356
 
2246
2357
  /** Every lock-owning scope visible from a directory, outermost → innermost.
@@ -2753,108 +2864,13 @@ function pruneCreatedAnchors(createdAnchors) {
2753
2864
  }
2754
2865
  }
2755
2866
 
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
- }
2867
+ /** One materializer for old acquisition/restore and the new preparation adapter:
2868
+ * dependencies -> complete containment/native checks -> projection -> stable v1
2869
+ * installation provenance -> digest. No source/selection/trust policy moves here. */
2870
+ const materializeCapability = createCapabilityMaterializer({
2871
+ materializeCapabilityDeps, assertCapabilitySelfContained,
2872
+ assertMaterializedDepsContained, assertNoNativeBinaries,
2873
+ });
2858
2874
 
2859
2875
  /** Config-template descriptors AND payload bytes, read from a staged package
2860
2876
  * before staging is discarded, so the config lane can offer or adopt a template
@@ -2938,10 +2954,14 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2938
2954
  const { dir: staging, createdAnchors, ignore } = beginStaging(levelDir);
2939
2955
  const artifactsDir = join(staging, "artifacts");
2940
2956
  mkdirSync(artifactsDir, { recursive: true });
2941
- const resolved = new Map(); // identity → staged package record
2942
2957
  let counter = 0;
2943
- const resolveClosure = (srcSpec, chain, baseDir) => {
2958
+ const readPackage = (srcSpec, parent, chain) => {
2959
+ const baseDir = parent?.parsedSource.kind === "path" ? parent.parsedSource.path : undefined;
2944
2960
  const p = parsePackageSource(srcSpec, { baseDir });
2961
+ if (parent) {
2962
+ if (p.kind === "git" && !p.ref) throw oatsError("invalid-source", `package dependency must be pinned to a tag/commit: "${srcSpec}" (declared by ${parent.package})`);
2963
+ 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`);
2964
+ }
2945
2965
  const dest = join(staging, `pkg-${counter++}`);
2946
2966
  let commit, packagePath;
2947
2967
  if (!chain.length && opts.rootSnapshot) {
@@ -2962,7 +2982,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2962
2982
  } else ({ commit, path: packagePath } = fetchPackageSource(p, dest, opts.catalog));
2963
2983
  const m = loadPackageManifestAt(dest);
2964
2984
  const id = m.package;
2965
- if (chain.includes(id)) throw oatsError("dependency-cycle", `package dependency cycle: ${[...chain, id].join(" → ")}`, [...chain, id]);
2966
2985
  // Preserve the ORIGINAL catalog spec in lock metadata: bare and explicit
2967
2986
  // selector forms must remain distinguishable for update. The resolved git
2968
2987
  // commit is already pinned separately in `commit`.
@@ -2971,36 +2990,21 @@ export function acquirePackage(levelDir, spec, opts = {}) {
2971
2990
  // contain several packages (contract §1.1), so two payload roots claiming one
2972
2991
  // package identity are a collision, not the same package resolved twice.
2973
2992
  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, {
2993
+ return {
2997
2994
  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;
2995
+ version: m.version, capabilities: m._capabilities, parsedSource: p, dependencyRequests: m.dependencies || [],
2996
+ };
3001
2997
  };
3002
2998
  try {
3003
- const rootId = resolveClosure(spec, [], undefined);
2999
+ const { roots: [rootId], packages: resolved } = resolvePackageClosure({
3000
+ requests: [spec], readPackage, context: levelDir,
3001
+ validatePackage(record) {
3002
+ const compat = capabilityCompatibility(record.manifest);
3003
+ if (!compat.compatible) throw oatsError("incompatible-oats", `package ${record.package} requires OATS ${compat.range} (running ${OATS_VERSION})`);
3004
+ },
3005
+ finalizePackage: (record, deps) => ({ ...record, integrity: packageIntegrity(record.dir), deps }),
3006
+ discardPackage: (record) => rmSync(record.dir, { recursive: true, force: true }),
3007
+ });
3004
3008
  if (opts.expectPackage && rootId !== opts.expectPackage) {
3005
3009
  throw oatsError("duplicate-package-identity", `source ${spec} no longer provides root package "${opts.expectPackage}" (root resolved to "${rootId}")`);
3006
3010
  }
@@ -3187,97 +3191,6 @@ export function acquirePackage(levelDir, spec, opts = {}) {
3187
3191
  }
3188
3192
  }
3189
3193
 
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
3194
  /** Fetch ONE locked package's exact provenance into staging and return the
3282
3195
  * verified payload root. Used by restore, update planning, migration and the
3283
3196
  * locked-template reader — every path that needs the exact bytes a package row
@@ -3661,33 +3574,27 @@ export function readLockedConfigTemplates(startDir, packageId, opts = {}) {
3661
3574
  } finally { rmSync(tmp, { recursive: true, force: true }); }
3662
3575
  }
3663
3576
 
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
3577
  /** Atomic file replacement: write to a same-directory temp file, then rename
3681
3578
  * over the destination — an interrupted write leaves the original bytes intact
3682
3579
  * (reviewer-21849d4). */
3683
- function atomicWriteFileSync(file, content) {
3580
+ function atomicWriteFileSync(file, content, { assertRoots } = {}) {
3684
3581
  const tmp = join(dirname(file), `.${basename(file)}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`);
3582
+ let written = false;
3685
3583
  try {
3686
- writeFileSync(tmp, content);
3584
+ assertRoots?.();
3585
+ writeFileSync(tmp, content, assertRoots ? { flag: "wx", mode: 0o600 } : undefined); written = true;
3586
+ assertRoots?.();
3687
3587
  renameSync(tmp, file);
3688
- } catch (e) {
3689
- rmSync(tmp, { force: true });
3690
- throw e;
3588
+ } catch (error) {
3589
+ // Never follow a replacement parent to clean up a same-named temp file.
3590
+ // Unguarded legacy callers retain their existing behavior.
3591
+ if (assertRoots && !written) throw error;
3592
+ try { assertRoots?.(); rmSync(tmp, { force: true }); }
3593
+ catch (cleanup) {
3594
+ const failure = new AggregateError([error, cleanup], "metadata publication and owned cleanup failed", { cause: error });
3595
+ failure.code = error.code || cleanup.code; throw failure;
3596
+ }
3597
+ throw error;
3691
3598
  }
3692
3599
  }
3693
3600
 
@@ -4170,15 +4077,8 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
4170
4077
  if (cap.inject && existsSync(cap.inject)) wanted.push([`capability:${cap.id}`, cap.inject]);
4171
4078
  }
4172
4079
  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 };
4080
+ const blocks = wanted.map(([source, file]) => ({ source, file, content: readFileSync(file, "utf8").trim() }));
4081
+ return { text: renderInstructionText(readFileSync(agentsMd, "utf8"), blocks), blocks, resolved };
4182
4082
  }
4183
4083
 
4184
4084
  /** The skill entries a tree contributes — THE discovery rule, shared by preflight
@@ -4726,6 +4626,187 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4726
4626
  return results;
4727
4627
  }
4728
4628
 
4629
+ /** Derive one selected supplemental input from the exact hook owner's binding
4630
+ * and already-validated generic target. Never infer dependency from a slot/name. */
4631
+ function capturedHookSourceReceipt(loaded) {
4632
+ const { record, capability, invocation } = loaded, target = invocation?.instance;
4633
+ const declaration = capability && hookDeclaration(capability.manifest.hooks?.[invocation?.action.name]);
4634
+ 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");
4635
+ const binding = record.bindings[capability.manifest.layer];
4636
+ if (!binding || binding.capability !== capability.id) throw oatsError("needs-configuration", "source receipt requires the opting capability's actual selected binding");
4637
+ const roleFile = loaded.resources.get(record.dispatch.composition?.body);
4638
+ if (!roleFile) throw oatsError("invalid-resolution", "selected source input lacks its canonical body resource");
4639
+ return validateCapturedSourceReceipt({ schemaVersion: 1, kind: record.subject.kind,
4640
+ home: target.home, work: target.work, context: invocation.executionBinding.deployment,
4641
+ agent: target.agent, instance: target.name, sourceIdentity: record.subject.kind === "persistent" ? record.subject.soul.identity : null,
4642
+ role: decodeUtf8(readPortableBytes(roleFile)), executionBinding: invocation.executionBinding,
4643
+ responsibleHuman: invocation.responsibleHuman, binding });
4644
+ }
4645
+
4646
+ /** Captured lifecycle execution uses the same hook result contract, but every
4647
+ * executable/settings/binding input comes from one verified resolution. It
4648
+ * preflights all applicable hooks before the first lifecycle side effect. */
4649
+ export function runCapturedLifecycleHooks(event, { deployment, resolution, home, instance, agentName, priorMeta = {}, extraEnv = {}, sourceReceipt, assertRoots, retryIntents = {} }) {
4650
+ if (!APPROVED_HOOKS.has(event)) throw oatsError("unsupported-action", `unsupported captured lifecycle event ${JSON.stringify(event)}`);
4651
+ for (const [name, value] of [["home", home], ["deployment", deployment]]) if (typeof value !== "string" || !isAbsolute(value)) throw oatsError("invalid-declaration", `${name} must be absolute`);
4652
+ if (typeof instance !== "string" || !instance || typeof agentName !== "string" || !agentName) throw oatsError("invalid-declaration", "captured lifecycle needs instance and agent names");
4653
+ if (sourceReceipt !== undefined) validateCapturedSourceReceipt(sourceReceipt);
4654
+ const inspected = loadCapturedDispatch({ deployment, resolution, action: { kind: "inspect" } });
4655
+ if (sourceReceipt) {
4656
+ const { record } = inspected, persistent = record.subject.kind === "persistent";
4657
+ const expectedAgent = persistent ? record.subject.soul.alias : record.subject.name;
4658
+ const expectedIdentity = persistent ? record.subject.soul.identity : null;
4659
+ const expectedHuman = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
4660
+ const expectedBinding = Object.values(record.bindings).find((binding) => binding.capability === sourceReceipt.binding.capability);
4661
+ const bodyKey = record.dispatch.composition?.body, bodyFile = bodyKey ? inspected.resources.get(bodyKey) : undefined;
4662
+ let role; try { role = bodyFile ? readFileSync(bodyFile, "utf8") : undefined; } catch { role = undefined; }
4663
+ let sameDeployment = false;
4664
+ try { sameDeployment = portableScope(sourceReceipt.executionBinding.deployment) === portableScope(deployment); } catch { /* invalid scope refuses below */ }
4665
+ if (sourceReceipt.kind !== (persistent ? "persistent" : "helper") || sourceReceipt.home !== resolve(home)
4666
+ || sourceReceipt.work !== join(resolve(home), "work") || sourceReceipt.agent !== expectedAgent || agentName !== expectedAgent || sourceReceipt.instance !== instance
4667
+ || sourceReceipt.executionBinding.resolution.id !== resolution.id || !sameDeployment
4668
+ || canonicalJson(sourceReceipt.sourceIdentity) !== canonicalJson(expectedIdentity)
4669
+ || canonicalJson(sourceReceipt.responsibleHuman) !== canonicalJson(expectedHuman)
4670
+ || !expectedBinding || canonicalJson(sourceReceipt.binding) !== canonicalJson(expectedBinding)
4671
+ || role === undefined || sourceReceipt.role !== role) {
4672
+ throw oatsError("invalid-resolution", "captured source receipt differs from the verified resolution or lifecycle target");
4673
+ }
4674
+ }
4675
+ const ids = Object.keys(inspected.record.dispatch.providerManifests);
4676
+ if (event === "retire") ids.reverse();
4677
+ const entries = ids.flatMap((id) => {
4678
+ const capability = inspected.capabilities.get(id), declaration = hookDeclaration(capability?.manifest?.hooks?.[event]);
4679
+ return declaration ? [{ id, capability, declaration }] : [];
4680
+ });
4681
+ 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");
4682
+ objectAt(retryIntents, entries.map(({ id }) => id), []);
4683
+ const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [], intents: {} };
4684
+ const checkRoots = () => {
4685
+ try { assertRoots?.(); }
4686
+ catch (error) { error.capturedHooks = { meta: results.meta, intents: results.intents, failures: results.failures, order: results.order }; throw error; }
4687
+ };
4688
+ const envOwners = new Map(), envDeclarations = new Map(entries.map(({ id, capability }) => [id, {
4689
+ names: new Set(capability.manifest.environment || []), namespaces: [...(capability.manifest.environmentNamespaces || [])],
4690
+ }]));
4691
+ const ready = [];
4692
+ for (const entry of entries) {
4693
+ const options = { deployment, resolution, action: { kind: "hook", capability: entry.id, name: event },
4694
+ invocationTarget: { home, work: join(home, "work"), name: instance, agent: agentName },
4695
+ ...(Object.hasOwn(priorMeta, entry.id) ? { priorReceipt: priorMeta[entry.id] } : {}) };
4696
+ let staticLoaded;
4697
+ try {
4698
+ // Check every static source/target/approval/executable before any effects.
4699
+ // Readiness is action-specific and receives its admitted intent below.
4700
+ staticLoaded = loadCapturedAction(options, capturedDispatchCodecs(options, { admitting: true }));
4701
+ } catch (error) {
4702
+ const detail = String(error.message || error).slice(0, 200);
4703
+ results.warnings.push(`${entry.id} ${event} hook unavailable: ${detail}`);
4704
+ results.failures.push({ capability: entry.id, event, message: detail, required: entry.declaration.required });
4705
+ if (event === "spawn" && entry.declaration.required) return results;
4706
+ continue;
4707
+ }
4708
+ // Selected-input authority failures are not optional hook functionality.
4709
+ // Derive ALL requested owner inputs before any provider check/effect.
4710
+ const selectedReceipt = entry.declaration.inputs?.sourceReceipt ? capturedHookSourceReceipt(staticLoaded) : null;
4711
+ if (sourceReceipt?.binding.capability === entry.id && canonicalJson(sourceReceipt) !== canonicalJson(selectedReceipt)) throw oatsError("invalid-resolution", "explicit receipt differs from the derived opted-in input");
4712
+ ready.push({ ...entry, options, selectedReceipt, instanceFacts: staticLoaded.invocation.instance });
4713
+ }
4714
+ for (const { id, capability, declaration, options, selectedReceipt, instanceFacts } of ready) {
4715
+ checkRoots(); results.order.push(id);
4716
+ const env = { ...process.env };
4717
+ for (const key of Object.keys(env)) if (key.startsWith("OATS_") || key.startsWith("PI_AGENT_") || key === "PI_AGENTS_ROOT") delete env[key];
4718
+ Object.assign(env, extraEnv, {
4719
+ OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
4720
+ OATS_CAPABILITY: id, OATS_CAPABILITY_ROOT: capability.manifest._dir, OATS_LAYER: capability.manifest.layer || "",
4721
+ OATS_CONTEXT: deployment, OATS_WORKSPACE: deployment, OATS_LEVEL: deployment,
4722
+ OATS_DEPLOYMENT: deployment, OATS_RESOLUTION: resolution.id,
4723
+ OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")), OATS_SETTINGS: JSON.stringify(capability.settings),
4724
+ OATS_META: JSON.stringify(priorMeta[id] || {}),
4725
+ });
4726
+ // Caller extras cannot nominate an input file, including when no owner
4727
+ // selected that input. Only private wrappers below supply these variables.
4728
+ for (const key of ["OATS_SOURCE_RECEIPT_FILE", "OATS_INVOCATION_CONTEXT_FILE", "OATS_BINDING_FILE"]) delete env[key];
4729
+ let admission, started = false;
4730
+ try {
4731
+ admission = admitCapturedAction({ deployment, resolution, home, action: options.action,
4732
+ ...(Object.hasOwn(priorMeta, id) ? { priorReceipt: priorMeta[id] } : {}),
4733
+ ...(Object.hasOwn(retryIntents, id) ? { retryExecutionId: retryIntents[id] } : {}) });
4734
+ results.intents[id] = admission.intent;
4735
+ if (admission.replayed) {
4736
+ // Never re-execute completed hooks to reconstruct runtime contributions.
4737
+ if (!admission.replayable) throw oatsError("needs-configuration", "completed hook has non-replayable runtime contributions");
4738
+ if (admission.receipt !== null) results.meta[id] = admission.receipt;
4739
+ continue;
4740
+ }
4741
+ const admittedOptions = { ...options, intent: admission.intent, priorReceipt: admission.receipt };
4742
+ const admitted = loadCapturedAction(admittedOptions, capturedDispatchCodecs(admittedOptions, { admitting: true }));
4743
+ if (canonicalJson(admitted.invocation.instance) !== canonicalJson(instanceFacts)) throw oatsError("selection-changed", "hook incarnation changed after input preflight");
4744
+ if (selectedReceipt && canonicalJson(capturedHookSourceReceipt(admitted)) !== canonicalJson(selectedReceipt)) throw oatsError("selection-changed", "selected source input changed after admission");
4745
+ const loaded = loadCapturedDispatch(admittedOptions);
4746
+ env.OATS_META = JSON.stringify(admission.receipt ?? {});
4747
+ const invoke = (snapshotEnv = {}) => withCapturedInvocationContextFile(loaded.invocation, contextEnv => withCapturedBindingFile(loaded, bindingEnv => {
4748
+ checkRoots();
4749
+ beginCapturedIntent({ deployment, home, intent: admission.intent, action: options.action }); started = true;
4750
+ 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 } });
4751
+ }));
4752
+ const stdout = selectedReceipt ? withCapturedSourceReceiptFile(home, selectedReceipt, invoke) : invoke();
4753
+ const lastLine = String(stdout).trim().split("\n").filter(Boolean).pop() || "{}";
4754
+ let output = {}; try { output = JSON.parse(lastLine); } catch { /* non-JSON hook output is allowed */ }
4755
+ if (output.meta) results.meta[id] = output.meta;
4756
+ if (output.brief) results.briefs.push(`- ${output.brief}`);
4757
+ if (output.warning) results.warnings.push(output.warning);
4758
+ 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}`;
4759
+ 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)) {
4760
+ results.contributions.push({ capability: id, layer: capability.manifest.layer || null, level: deployment, settings: { ...capability.settings },
4761
+ trust: { trusted: true, integrity: inspected.record.artifacts.capabilities[id].artifact.integrity.value },
4762
+ launch: output.launch && typeof output.launch === "object" ? { ...output.launch } : {}, env: output.env && typeof output.env === "object" ? Object.keys(output.env).sort() : [] });
4763
+ }
4764
+ if (output.env !== undefined) {
4765
+ if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${id} hook env is supported only for spawn and launch, not ${event}`);
4766
+ Object.assign(results.env, validateHookEnvironment(id, output.env, envOwners, envDeclarations));
4767
+ }
4768
+ settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action, state: "completed",
4769
+ receipt: results.meta[id] ?? admission.receipt,
4770
+ replayable: !Object.keys(output.env || {}).length && !Object.keys(output.launch || {}).length });
4771
+ } catch (error) {
4772
+ // A nested private-snapshot cleanup failure must not erase a hook's
4773
+ // already-observed provider receipt. Unwrap bounded owned error causes;
4774
+ // never reinterpret that receipt as a successful/fully cleaned lifecycle.
4775
+ let observed, reported;
4776
+ const seen = new Set(); let cause = error;
4777
+ while (cause && typeof cause === "object" && !seen.has(cause) && seen.size < 16) {
4778
+ seen.add(cause);
4779
+ if (cause.invocationCompleted && typeof cause.invocationResult === "string") { observed = cause.invocationResult; break; }
4780
+ if (typeof cause.stdout === "string" || Buffer.isBuffer(cause.stdout)) { observed = cause.stdout; break; }
4781
+ cause = cause.cause;
4782
+ }
4783
+ try {
4784
+ const output = JSON.parse(String(observed ?? "").trim().split("\n").filter(Boolean).pop() || "{}");
4785
+ if (output?.meta) results.meta[id] = output.meta;
4786
+ if (typeof output?.warning === "string" && output.warning.trim()) reported = output.warning.trim();
4787
+ } catch { /* non-JSON hook failure output is allowed */ }
4788
+ let custodyError;
4789
+ if (admission && !admission.replayed) {
4790
+ try { settleCapturedIntent({ deployment, home, intent: admission.intent, action: options.action,
4791
+ state: started ? "unconfirmed" : "blocked", receipt: results.meta[id] ?? admission.receipt }); }
4792
+ catch (failure) { custodyError = failure; } // Possibly published: retain, never erase/re-admit.
4793
+ }
4794
+ const environment = error instanceof HookEnvironmentContractError;
4795
+ const cleanup = error instanceof AggregateError;
4796
+ const required = environment || cleanup || !!custodyError || declaration.required;
4797
+ const detail = reported || String(error.message || error).slice(0, 200);
4798
+ results.warnings.push(`${id} ${event} hook ${environment ? "environment contract " : ""}failed: ${detail}`);
4799
+ results.failures.push({ capability: id, event, message: detail, required,
4800
+ ...(started || custodyError ? { unconfirmed: true } : {}),
4801
+ ...(custodyError ? { custody: { code: custodyError.code || "E_INTENT_CUSTODY", message: String(custodyError.message).slice(0, 200) } } : {}),
4802
+ ...(environment ? { contract: "environment" } : {}),
4803
+ ...(cleanup ? { contract: "snapshot-cleanup", unconfirmed: true, cleanup: { code: error.code || "E_HOOK_CLEANUP", message: String(error.message || error).slice(0, 200) } } : {}) });
4804
+ if (event === "spawn" && required) return results;
4805
+ } finally { checkRoots(); }
4806
+ }
4807
+ return results;
4808
+ }
4809
+
4729
4810
  // ---------- agents ----------
4730
4811
  /** All local-agent base dirs readable for a root: the scope sibling (canonical)
4731
4812
  * plus legacy nested locations. */
@@ -5452,7 +5533,9 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5452
5533
  const hookArgs = recipe.hooks?.launch?.[runtime] || "";
5453
5534
  const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
5454
5535
  let cmdline;
5455
- if (runtime === "claude") {
5536
+ if (isPiSdkHost(recipe)) {
5537
+ cmdline = `${shq(executable)} ${piHostArgv(recipe, { home, sessionDir: capturedPiSessionDirectory(home) }).map(shq).join(" ")}`;
5538
+ } else if (runtime === "claude") {
5456
5539
  cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5457
5540
  } else if (runtime === "codex") {
5458
5541
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
@@ -5472,15 +5555,27 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5472
5555
  /** Execution, not preview: mark pending before dispatch, then resolve native
5473
5556
  * storage inside the backend shell under the actual command environment.
5474
5557
  * The original executable/argv is exec'd unchanged after recording succeeds. */
5475
- function nativeRecordCommand(command, home, runtime) {
5558
+ function nativeRecordCommand(command, home, runtime, onPrepared, prepare = prepareNativeStart) {
5476
5559
  const { tokens, binary } = parseLaunchCommand(command);
5477
5560
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
5478
- const id = prepareNativeStart(home, runtime);
5561
+ const id = prepare(home, runtime);
5562
+ onPrepared?.(id);
5479
5563
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
5480
5564
  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
5565
  return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
5482
5566
  }
5483
5567
 
5568
+ /** Common tmux/Herdr completion wrapper for the exact captured Pi SDK profile.
5569
+ * Save the actual native chain's shell status BEFORE the observer. Reuse the
5570
+ * original command environment (including its ORIGINAL attempt); no new intent
5571
+ * or process identity is constructed. The observer never replaces this status. */
5572
+ export function renderCapturedPiCompletion(command, executionCommand, { home, nativeRecordId }) {
5573
+ const { tokens, binary } = parseLaunchCommand(command);
5574
+ const prefix = tokens.slice(0, binary).map(token => token.text).join(" ");
5575
+ const args = ["--oats-pi-record-exit", "1", "--home", home, "--native-record", nativeRecordId].map(shq).join(" ");
5576
+ return `${executionCommand}; oats_start_status=$?; ${prefix} ${shq(process.execPath)} ${shq(PI_SDK_HOST)} ${args} --exit-status "$oats_start_status"`;
5577
+ }
5578
+
5484
5579
  /** A recorded recipe this kernel understands, or a refusal before anything
5485
5580
  * is observed or stopped. */
5486
5581
  export function assertLaunchRecipe(recipe, what) {
@@ -6744,6 +6839,7 @@ function sessionDirectoryGuard(home) {
6744
6839
  let meta;
6745
6840
  try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
6746
6841
  if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
6842
+ 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
6843
  };
6748
6844
  check();
6749
6845
  return check;
@@ -6953,13 +7049,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
6953
7049
  return roots;
6954
7050
  }
6955
7051
 
6956
- function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
7052
+ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
6957
7053
  if (mode === "directory") assertDirectoryRoots(home);
6958
7054
  const isWorktree = mode === "worktree";
6959
7055
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
6960
7056
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
6961
7057
  const baseline = {
6962
7058
  version: RETIRE_BASELINE_VERSION,
7059
+ ...(incarnationId ? { incarnationId, executionBinding } : {}),
6963
7060
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
6964
7061
  home: realPathOrNearest(home),
6965
7062
  homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
@@ -6973,7 +7070,7 @@ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runti
6973
7070
  };
6974
7071
  const path = retirementBaselinePath(home);
6975
7072
  mkdirSync(dirname(path), { recursive: true });
6976
- writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600 });
7073
+ writeFileSync(path, JSON.stringify(baseline, null, 2) + "\n", { mode: 0o600, ...(exclusive ? { flag: "wx" } : {}) });
6977
7074
  }
6978
7075
 
6979
7076
  function nestedGitRoots(root) {
@@ -7431,12 +7528,251 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
7431
7528
  * per-home lock and pending receipt across stop and launch, every preflight
7432
7529
  * before the stop, a bounded SIGTERM with no escalation, factual receipts.
7433
7530
  * Not a retirement: home, work, identity and notes stay. */
7531
+ /** Exact captured plan, existing native session transaction. This is dispatch
7532
+ * custody, not provider enrollment or proof that a model finished its task. */
7533
+ export function startCapturedInstanceSession(home, options = {}) {
7534
+ objectAt(options, ["deployment", "resolution", "backend", "task", "retryExecutionId", "restart", "env", "io", "stopGraceMs"], []);
7535
+ 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");
7536
+ let metadata = readCapturedInstanceMetadata(home);
7537
+ home = metadata.home;
7538
+ const binding = metadata.executionBinding, deployment = portableScope(options.deployment ?? binding.deployment), resolution = options.resolution ?? binding.resolution;
7539
+ if (canonicalJson({ schemaVersion: 1, deployment, resolution }) !== canonicalJson(binding)) throw oatsError("invalid-resolution", "captured start selector differs from the owned home");
7540
+ const original = readCapturedInstanceAuthority(deployment, home).row;
7541
+ 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");
7542
+ const latestSession = original.intents.findLast(intent => intent.action.kind === "session");
7543
+ const heldLifecycle = (message, indexedStatus = original.status) => {
7544
+ const error = oatsError("selection-changed", message);
7545
+ error.home = home;
7546
+ error.capturedCustody = { incarnationId: original.incarnationId, executionBinding: binding,
7547
+ indexedStatus, metadataStatus: metadata.captured.lifecycle, held: true,
7548
+ ...(latestSession ? { intent: { schemaVersion: 1, executionId: latestSession.executionId, incarnationId: original.incarnationId, attempt: latestSession.attempt }, receipt: latestSession.receipt } : {}) };
7549
+ throw error;
7550
+ };
7551
+ const sourceStatus = metadata.captured.lifecycle;
7552
+ 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");
7553
+ if (sourceStatus === "start-failed-cleanup-required") {
7554
+ if (!latestSession || options.retryExecutionId !== latestSession.executionId) heldLifecycle("native cleanup requires its exact saved retry, not a new request");
7555
+ if (latestSession.state === "completed") heldLifecycle("completed native dispatch still has publication debt; explicit reconciliation is required");
7556
+ }
7557
+ let ownedState = sourceStatus;
7558
+ const assertRoots = () => {
7559
+ const row = assertCapturedInstanceCustody(original);
7560
+ if (row.status !== ownedState) heldLifecycle("native start no longer owns lifecycle state", row.status);
7561
+ const history = nativeHistoryPath(home), baseline = retirementBaselinePath(home);
7562
+ for (const [key, path] of Object.entries({ historyRoot: dirname(history), history, retirementRoot: dirname(dirname(baseline)), baselines: dirname(baseline) })) {
7563
+ if (!lstatSync(path).isDirectory() || canonicalJson(directoryIdentity(path)) !== canonicalJson(original.nativeScaffold.directories[key])) throw oatsError("integrity-drift", "native custody directory was replaced");
7564
+ }
7565
+ // The strict Pi record owner validates its private manifest via the pinned
7566
+ // inspect/prepare/started APIs. Its approved expected-ID claim legitimately
7567
+ // advances v1 -> v2 INSIDE prepare's assertAuthority callbacks. This callback
7568
+ // guards kernel custody, not a duplicate/reentrant record codec. Freezing
7569
+ // record-owned bytes to v1 here makes every valid Pi claim fail mid-write.
7570
+ // Literal legacy manifest custody stays unchanged for other runtimes.
7571
+ if (!piHost) {
7572
+ const manifest = parseStrictJson(readPortableBytes(join(history, "history.json")));
7573
+ if (canonicalJson(manifest) !== canonicalJson({ version: 1, home, completeHistory: true })) throw oatsError("integrity-drift", "native history scaffold authority changed");
7574
+ }
7575
+ const current = readCapturedInstanceMetadata(home);
7576
+ assertCapturedSessionPlacement(current, backend, knownTargets);
7577
+ const saved = parseStrictJson(readPortableBytes(baseline)), authority = runtimeAuthorityOf(saved);
7578
+ if (saved.version !== RETIRE_BASELINE_VERSION || saved.home !== home || saved.incarnationId !== original.incarnationId
7579
+ || canonicalJson(saved.executionBinding) !== canonicalJson(binding) || !authority) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline differs from captured incarnation");
7580
+ if (authority.launched) {
7581
+ const target = authority.sessionTarget ?? { backend: "tmux", ...authority.tmux };
7582
+ validateCapturedSessionTarget(target, backend, current.instance);
7583
+ if (backend.backend === "herdr" && !knownTargets.some(known => canonicalJson(known) === canonicalJson(target))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "native baseline target lacks indexed custody");
7584
+ } 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");
7585
+ 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");
7586
+ return row;
7587
+ };
7588
+ const loaded = loadCapturedDispatch({ deployment, resolution, action: { kind: "compose" } });
7589
+ const record = loaded.record, expectedAgent = record.subject.kind === "persistent" ? record.subject.soul.alias : record.subject.name;
7590
+ const human = record.messagingChoice.enabled ? record.messagingChoice.privateKey.human : null;
7591
+ 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");
7592
+ if (record.dispatch.composition?.mode !== "directory") throw oatsError("needs-configuration", "captured native start requires supported owned directory work");
7593
+ if (loaded.approvals.some(item => !["approved", "not-required"].includes(item.status))) throw oatsError("approval-required", "captured native start needs every exact executable approval");
7594
+ if (!record.dispatch.launch) throw oatsError("needs-configuration", "captured record has no explicit launch inputs");
7595
+ 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");
7596
+ 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");
7597
+ const recipe = structuredClone(record.dispatch.launch);
7598
+ if (recipe.executableResource) {
7599
+ const resource = loaded.resources.get(recipe.executableResource);
7600
+ if (!resource) throw oatsError("resolution-incomplete", "captured native entrypoint resource is missing");
7601
+ const manifest = loaded.manifests.get(recipe.entrypoint.capability), specification = manifest?.commands?.[recipe.entrypoint.command];
7602
+ if (typeof specification !== "string") throw oatsError("invalid-resolution", "retained native entrypoint is not declared");
7603
+ const [script, ...declaredArgs] = specification.trim().split(/\s+/), declaredFile = capabilityExecutablePath(manifest, script);
7604
+ if (!declaredFile || realpathSync(declaredFile) !== resource) throw oatsError("invalid-resolution", "native entrypoint resource differs from retained command");
7605
+ if (declaredArgs.length) throw oatsError("needs-configuration", "native entrypoint arguments require a qualified captured adapter");
7606
+ recipe.executable = resource;
7607
+ } 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");
7608
+ if (!recipe.executableResource && existsSync(recipe.executable)) {
7609
+ const actual = realpathSync(recipe.executable), managed = join(deployment, ".agents");
7610
+ 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");
7611
+ }
7612
+ const broken = checkLaunchExecutable(recipe.executable);
7613
+ if (broken) throw oatsError("E_LAUNCH_EXECUTABLE", broken);
7614
+ const piHost = isPiSdkHost(recipe);
7615
+ 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");
7616
+ if (recipe.args.length && !piHost) throw oatsError("needs-configuration", "native extra arguments require a qualified captured adapter; no flags were inferred or ignored");
7617
+ const piProfile = piHost ? validatePiHostRecipe(recipe) : null; // refuse retained contributions before normalization
7618
+ recipe.hooks = { launch: {}, env: {}, contributions: [] };
7619
+ const piSessionDir = piHost ? capturedPiSessionDirectory(home) : null;
7620
+ if (piHost) {
7621
+ resolvePiSdkEntry(piProfile); // public export metadata only; no SDK/auth/model import
7622
+ requireCapturedPiRecordSupport(); // refuse before admission/backend when guards are absent
7623
+ inspectCapturedPiRoot(home, { incarnationId: original.incarnationId, sessionDir: piSessionDir });
7624
+ }
7625
+ const baseEnv = { ...(options.env || process.env) };
7626
+ for (const key of Object.keys(baseEnv)) if (/^(OATS_|OAS_|PI_AGENT_)/.test(key) || key === "PI_AGENTS_ROOT") delete baseEnv[key];
7627
+ const missing = missingLaunchEnvRefs(recipe.env, baseEnv);
7628
+ if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", "captured native environment references are unavailable");
7629
+ const backend = Object.hasOwn(options, "backend") ? options.backend : metadata.captured.nativeBackend;
7630
+ if (backend === undefined) throw oatsError("needs-configuration", "first captured native start needs an explicit backend endpoint");
7631
+ validateCapturedSessionBackend(backend);
7632
+ if (checkLaunchExecutable(backend.binary)) throw oatsError("E_LAUNCH_EXECUTABLE", "selected native backend executable is unavailable");
7633
+ if (Object.hasOwn(metadata.captured, "nativeBackend") && canonicalJson(metadata.captured.nativeBackend) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "native backend relocation requires separate qualified custody");
7634
+ const sessionHistory = original.intents.filter(intent => intent.action.kind === "session");
7635
+ const targetReceipts = sourceStatus === "start-failed-cleanup-required" ? sessionHistory.slice(-2) : sessionHistory.slice(-1);
7636
+ const knownTargets = backend.backend === "herdr" ? targetReceipts.flatMap(intent => intent.receipt?.target?.backend === "herdr"
7637
+ ? [validateCapturedSessionTarget(intent.receipt.target, backend, metadata.instance)] : []) : [];
7638
+ const nativeTarget = backend.backend === "tmux" ? { backend: "tmux", session: backend.session, window: metadata.instance, socket: backend.socket }
7639
+ : latestSession?.receipt?.target?.backend === "herdr" ? validateCapturedSessionTarget(latestSession.receipt.target, backend, metadata.instance) : undefined;
7640
+ assertCapturedSessionPlacement(metadata, backend, knownTargets);
7641
+ if (options.restart !== undefined && typeof options.restart !== "boolean") throw oatsError("invalid-declaration", "restart must be boolean");
7642
+ const taskFile = join(home, "TASK.md");
7643
+ if (options.task !== undefined && typeof options.task !== "string") throw oatsError("invalid-declaration", "native task must be explicit text");
7644
+ const task = options.task === undefined ? decodeUtf8(readPortableBytes(taskFile)) : options.task;
7645
+ canonicalJson(task, { maxBytes: 512 * 1024 });
7646
+ if (task.includes("\0")) throw oatsError("invalid-declaration", "native task contains NUL");
7647
+ if (piHost && !task.trim()) throw oatsError("E_PI_HOST_TASK", "captured Pi print requires a nonempty task");
7648
+ const taskBytes = Buffer.from(task);
7649
+ const assertCurriculum = () => {
7650
+ assertRoots();
7651
+ if (decodeUtf8(readPortableBytes(join(home, "AGENTS.md"))) !== loaded.composition.text
7652
+ || !lstatSync(join(home, "CLAUDE.md")).isSymbolicLink() || readlinkSync(join(home, "CLAUDE.md")) !== "AGENTS.md"
7653
+ || !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");
7654
+ const actual = readdirSync(join(home, ".agents/skills")).sort(), expected = loaded.composition.skills.map(skill => skill.name).sort();
7655
+ if (canonicalJson(actual) !== canonicalJson(expected)) throw oatsError("integrity-drift", "native skill inventory differs from retained composition");
7656
+ 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");
7657
+ };
7658
+ assertCurriculum();
7659
+ const action = { kind: "session", name: options.restart ? "restart" : "start" };
7660
+ const input = { backend, taskIntegrity: { format: "oats.bytes.v1", value: `sha256-${createHash("sha256").update(taskBytes).digest("hex")}` } };
7661
+ const pendingPath = join(home, ".oats-start-pending.json");
7662
+ 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 })}`;
7663
+ if (existsSync(pendingPath)) {
7664
+ const pending = parseStrictJson(readPortableBytes(pendingPath)), ref = pending.capturedIntent;
7665
+ validateIntentRef(ref);
7666
+ if (typeof pending.nativeRecordId !== "string" || !/^[a-f0-9-]{36}$/.test(pending.nativeRecordId)) throw oatsError("invalid-resolution", "pending native history reference is invalid");
7667
+ const evidence = parseStrictJson(readPortableBytes(join(nativeHistoryPath(home), `${pending.nativeRecordId}.json`)));
7668
+ 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");
7669
+ if (piHost && (evidence.incarnationId !== original.incarnationId || canonicalJson(evidence.intent) !== canonicalJson(ref))) throw oatsError("invalid-resolution", "Pi native witness differs from the original admitted intent");
7670
+ const prior = original.intents.find(intent => intent.executionId === ref?.executionId);
7671
+ if (!prior || prior.action.kind !== "session" || latestSession.executionId !== pending.id || ref.incarnationId !== original.incarnationId || pending.id !== ref.executionId || ref.attempt > prior.attempt
7672
+ || canonicalJson(pending.launch) !== canonicalJson(recipe) || pending.command !== makeCommand(ref)) throw oatsError("invalid-resolution", "pending native receipt is not this captured launch");
7673
+ if (pending.phase === "allocating") {
7674
+ if (backend.backend !== "herdr" || canonicalJson(pending.endpoint) !== canonicalJson(backend)) throw oatsError("invalid-resolution", "pending allocation endpoint differs from admitted backend");
7675
+ const error = oatsError("E_SESSION_UNKNOWN", "native allocation outcome is unknown; retain the same intent and reconcile, never allocate again");
7676
+ error.home = home; error.nativeCustody = { intent: ref, pendingPath, observed: prior.receipt, unconfirmed: true }; throw error;
7677
+ }
7678
+ validateCapturedSessionTarget(pending.target, backend, metadata.instance);
7679
+ 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");
7680
+ }
7681
+ // Static target/curriculum/backend/task validation precedes provider code.
7682
+ // This read-only inspection is not a provider-native enrollment grant.
7683
+ for (const [slot, providerBinding] of Object.entries(record.bindings)) {
7684
+ const capability = loaded.capabilities.get(providerBinding.capability), inspection = { kind: "inspect" };
7685
+ const invocation = buildCapturedInvocationContext({ loaded: { ...loaded, capability }, action: inspection,
7686
+ instance: { home, work: join(home, "work"), name: metadata.instance, agent: metadata.agent } });
7687
+ const checked = runCapturedProviderBinding({ deployment, artifacts: record.artifacts, capability: capability.id, phase: "check", settings: capability.settings,
7688
+ input: { binding: providerBinding, context: record.context, action: inspection, invocation } });
7689
+ if (checked.status !== "ready") throw oatsError(checked.problems[0]?.code || "provider-not-qualified", `captured ${slot} provider is unavailable`);
7690
+ }
7691
+ assertCurriculum();
7692
+ const admission = admitCapturedInstanceAction({ deployment, home, executionBinding: binding, capability: null, action, input, expectedStatus: sourceStatus,
7693
+ ...(options.retryExecutionId !== undefined ? { retryExecutionId: options.retryExecutionId } : {}) });
7694
+ if (admission.replayed) return { ...admission.receipt, intent: admission.intent, replayed: true };
7695
+ let observed = null, pendingObservation = null;
7696
+ try {
7697
+ ownedState = "start-running";
7698
+ setCapturedInstanceStatus(deployment, home, ownedState, { expectedStatus: sourceStatus });
7699
+ beginCapturedIntent({ deployment, home, intent: admission.intent, action });
7700
+ atomicWriteFileSync(taskFile, task, { assertRoots });
7701
+ metadata = { ...metadata, backend: backend.backend,
7702
+ ...(backend.backend === "tmux" ? { tmux: { session: nativeTarget.session, window: nativeTarget.window, socket: nativeTarget.socket } } : {}),
7703
+ captured: { ...metadata.captured, nativeBackend: backend } };
7704
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson(metadata), { assertRoots });
7705
+ const plan = { recipe, command: makeCommand(admission.intent), runtime: recipe.runtime, model: recipe.model, yolo: recipe.yolo };
7706
+ const exec = (binary, args, executionOptions) => {
7707
+ assertCurriculum();
7708
+ if (!readPortableBytes(taskFile).equals(taskBytes)) throw oatsError("integrity-drift", "native task changed after admission");
7709
+ if (existsSync(pendingPath)) {
7710
+ const pending = parseStrictJson(readPortableBytes(pendingPath));
7711
+ if (pending.id === admission.intent.executionId) pendingObservation = { id: pending.id, startedAt: pending.startedAt,
7712
+ ...(pending.target ? { target: pending.target } : {}), ...(pending.endpoint ? { endpoint: pending.endpoint } : {}), ...(pending.phase ? { phase: pending.phase } : {}),
7713
+ ...(pending.nativeRecordId ? { nativeRecordId: pending.nativeRecordId } : {}), unconfirmed: true };
7714
+ }
7715
+ const env = { ...baseEnv };
7716
+ if (backend.backend === "herdr") {
7717
+ delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
7718
+ if (binary === backend.binary) {
7719
+ if (executionOptions.env?.HERDR_SOCKET_PATH !== backend.socket) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "Herdr transport socket differs from admitted endpoint");
7720
+ env.HERDR_SOCKET_PATH = backend.socket;
7721
+ }
7722
+ }
7723
+ return (options.io?.exec || execFileSync)(backend.backend === "tmux" && binary === "tmux" ? backend.binary : binary, args, { ...executionOptions, env });
7724
+ };
7725
+ const observeTarget = (target, facts) => {
7726
+ validateCapturedSessionTarget(target, backend, metadata.instance);
7727
+ pendingObservation = { ...facts, target, unconfirmed: true };
7728
+ knownTargets.push(target); assertRoots();
7729
+ observeCapturedNativeTarget(original, admission.intent, pendingObservation);
7730
+ };
7731
+ observed = startInstanceSession(home, { restart: !!options.restart, ...(options.stopGraceMs === undefined ? {} : { stopGraceMs: options.stopGraceMs }), env: baseEnv,
7732
+ io: { ...options.io, exec, strictHerdrTarget: backend.backend === "herdr" },
7733
+ [CAPTURED_NATIVE_START]: { plan, intent: admission.intent, endpoint: backend, target: nativeTarget, observeTarget, assertRoots,
7734
+ ...(piHost ? { prepareNativeRecord: () => {
7735
+ assertCurriculum();
7736
+ return prepareCapturedPiStart(home, { incarnationId: original.incarnationId, intent: admission.intent, sessionDir: piSessionDir, assertAuthority: assertRoots });
7737
+ } } : {}) } });
7738
+ assertRoots();
7739
+ settleCapturedIntent({ deployment, home, intent: admission.intent, action, state: "completed", receipt: observed, replayable: true });
7740
+ const next = readCapturedInstanceMetadata(home);
7741
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...next, captured: { ...next.captured, lifecycle: "start-dispatched", nativeIntent: admission.intent } }), { assertRoots });
7742
+ setCapturedInstanceStatus(deployment, home, "start-dispatched", { expectedStatus: ownedState });
7743
+ ownedState = "start-dispatched";
7744
+ return { ...observed, intent: admission.intent, incarnationId: original.incarnationId, dispatchAccepted: true };
7745
+ } catch (error) {
7746
+ let reportingFailure;
7747
+ try { failCapturedNativeIntent(original, admission.intent, observed ?? pendingObservation ?? admission.receipt); ownedState = "start-failed-cleanup-required"; }
7748
+ catch (failure) { reportingFailure = failure.code || "E_NATIVE_CUSTODY"; }
7749
+ try {
7750
+ assertRoots(); const current = readCapturedInstanceMetadata(home);
7751
+ atomicWriteFileSync(join(home, "instance.json"), canonicalJson({ ...current, captured: { ...current.captured, lifecycle: "start-failed-cleanup-required", nativeIntent: admission.intent } }), { assertRoots });
7752
+ } catch { /* never write/clean a replacement home */ }
7753
+ error.home = home; error.nativeCustody = { intent: admission.intent, pendingPath, observed, pendingObservation, unconfirmed: true, ...(reportingFailure ? { reportingFailure } : {}) };
7754
+ throw error;
7755
+ }
7756
+ }
7757
+
7434
7758
  export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
7435
7759
  export function startInstanceSession(home, o = {}) {
7436
7760
  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
7761
  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);
7762
+ const directoryGuard = sessionDirectoryGuard(home);
7763
+ if (!o[CAPTURED_NATIVE_START]) {
7764
+ let metadata; try { metadata = parseStrictJson(readPortableBytes(join(home, "instance.json"))); } catch { /* legacy receipt validation reports unreadable metadata */ }
7765
+ if (metadata?.executionBinding) {
7766
+ const portableOptions = { ...o };
7767
+ for (const key of ["model", "runtime", "launchConfig", "yolo"]) {
7768
+ if (portableOptions[key] !== undefined) throw oatsError("E_BAD_ARGS", "captured start cannot replace its recorded runtime/model/configuration");
7769
+ delete portableOptions[key]; // Legacy callers pass omitted options as undefined.
7770
+ }
7771
+ return startCapturedInstanceSession(home, portableOptions);
7772
+ }
7773
+ }
7774
+ const capturedStart = o[CAPTURED_NATIVE_START];
7775
+ const checkRoots = () => { directoryGuard(); capturedStart?.assertRoots(); };
7440
7776
  const realHome = realPathOrNearest(home);
7441
7777
  let originalHomeIdentity;
7442
7778
  try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
@@ -7460,7 +7796,7 @@ export function startInstanceSession(home, o = {}) {
7460
7796
  // The independent receipt first (retire and session consult it), then the
7461
7797
  // mutable metadata; both tmp+rename. A failure between them is what the
7462
7798
  // pending receipt exists for.
7463
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
7799
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId }, clearPending = true) => {
7464
7800
  checkRoots();
7465
7801
  const baselinePath = retirementBaselinePath(realHome);
7466
7802
  let baseline;
@@ -7478,9 +7814,10 @@ export function startInstanceSession(home, o = {}) {
7478
7814
  ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
7479
7815
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
7480
7816
  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 } : {}) };
7817
+ if (capturedStart) atomicWriteFileSync(metaPath, canonicalJson(next), { assertRoots: checkRoots });
7818
+ else writeJsonAtomic(metaPath, next);
7819
+ if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
7820
+ 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
7821
  };
7485
7822
  try { mkdirSync(lock); }
7486
7823
  catch (e) {
@@ -7526,11 +7863,15 @@ export function startInstanceSession(home, o = {}) {
7526
7863
  const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
7527
7864
  if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
7528
7865
  }
7866
+ 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
7867
  // Reconcile even an exited target: the independent baseline may
7530
7868
  // already name it while metadata still names the old allocation.
7531
7869
  const meta = readMeta();
7532
7870
  const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
7533
7871
  if (st.present && st.state !== "shell") {
7872
+ // Same logical captured request already dispatched: adopting it is the
7873
+ // retry outcome, even for restart. Only a DISTINCT restart may continue.
7874
+ if (capturedStart && pending.id === capturedStart.intent.executionId) return done;
7534
7875
  if (o.restart) { rmSync(pendingPath, { force: true }); }
7535
7876
  else {
7536
7877
  // The recovered target runs what the receipt says; a choice made
@@ -7545,9 +7886,10 @@ export function startInstanceSession(home, o = {}) {
7545
7886
  // 2. The ordinary gate and observation, all under the lock.
7546
7887
  const receipt = instanceSessionTarget(realHome);
7547
7888
  const meta = readMeta();
7548
- const runtime = meta.runtime;
7889
+ const runtime = capturedStart?.plan.runtime || meta.runtime;
7549
7890
  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";
7891
+ const backend = capturedStart ? capturedStart.endpoint.backend : (meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux");
7892
+ 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
7893
  let command = meta.command;
7552
7894
  let model = meta.model || undefined;
7553
7895
  // What this start launches: the frozen command (optionally with another
@@ -7556,8 +7898,9 @@ export function startInstanceSession(home, o = {}) {
7556
7898
  // preflight happens here, before anything is observed or stopped.
7557
7899
  const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
7558
7900
  const hasRecipe = meta.launch && typeof meta.launch === "object";
7559
- let launchPlan = null;
7560
- if (selected || hasRecipe) {
7901
+ let launchPlan = capturedStart?.plan || null;
7902
+ if (capturedStart) { command = launchPlan.command; model = launchPlan.model; }
7903
+ else if (selected || hasRecipe) {
7561
7904
  // Every start of a home with a recipe (ordinary, model-only, or under a
7562
7905
  // selection) goes through the one planner: recipe shape, the recorded
7563
7906
  // or selected executable, references, capability contributions under
@@ -7583,7 +7926,8 @@ export function startInstanceSession(home, o = {}) {
7583
7926
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
7584
7927
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
7585
7928
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
7586
- const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
7929
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
7930
+ ...(capturedStart ? { capturedIntent: capturedStart.intent } : {}) } : {};
7587
7931
  let target = receipt.target;
7588
7932
  let state = { present: false, state: "not-launched" };
7589
7933
  let serverGone = false;
@@ -7607,28 +7951,40 @@ export function startInstanceSession(home, o = {}) {
7607
7951
  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
7952
  }
7609
7953
  const startedAt = new Date().toISOString();
7610
- const id = randomUUID();
7954
+ const id = capturedStart?.intent.executionId || randomUUID();
7611
7955
  checkRoots();
7612
- const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
7613
- const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7956
+ const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime,
7957
+ capturedStart ? id => { planExtra.nativeRecordId = id; } : undefined, capturedStart?.prepareNativeRecord);
7958
+ const completedCommand = capturedStart && isPiSdkHost(launchPlan?.recipe)
7959
+ ? renderCapturedPiCompletion(command, executionCommand, { home: realHome, nativeRecordId: planExtra.nativeRecordId })
7960
+ : `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7614
7961
  let reused = "new";
7615
7962
  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`);
7963
+ if (!target && !capturedStart) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
7964
+ const base = capturedStart?.endpoint ?? { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
7617
7965
  if (state.present) { reused = "pane"; }
7618
7966
  else {
7619
- const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
7620
7967
  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);
7968
+ catch (e) {
7969
+ if (capturedStart) throw oatsError("E_SESSION_UNKNOWN", "selected Herdr endpoint is unavailable or incompatible; no fallback was started");
7970
+ throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`);
7971
+ }
7972
+ if (capturedStart) {
7973
+ checkRoots();
7974
+ writeJsonAtomic(pendingPath, { id, phase: "allocating", endpoint: base, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7975
+ }
7976
+ try { target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io); }
7977
+ catch (e) { if (capturedStart) throw launchFailure("Herdr allocation", e); throw e; }
7623
7978
  }
7979
+ if (capturedStart) capturedStart.observeTarget(target, { id, startedAt, nativeRecordId: planExtra.nativeRecordId });
7624
7980
  checkRoots();
7625
7981
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7626
7982
  try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
7627
7983
  catch (e) { throw launchFailure("Herdr", e); }
7628
7984
  } 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;
7985
+ const session = capturedStart?.target.session ?? (target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION);
7986
+ const window = capturedStart?.target.window ?? (target?.window || meta.tmux?.window || meta.instance);
7987
+ let socket = capturedStart?.target.socket ?? (target?.socket || meta.tmux?.socket);
7632
7988
  const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
7633
7989
  // A fallback shell (no harness descendant) or a retained dead pane is
7634
7990
  // the agent's own pane: the command runs there, no other window touched.