@awebai/oats 0.23.2 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +224 -391
  2. package/bin/oats-pi-sdk-host.mjs +17 -0
  3. package/bin/oats.mjs +470 -51
  4. package/capabilities/oats-okf/bin/oats-okf-binding.mjs +14 -0
  5. package/capabilities/oats-okf/bin/oats-okf.mjs +79 -11
  6. package/capabilities/oats-okf/lib/binding-wire.mjs +268 -0
  7. package/capabilities/oats-okf/lib/captured-worker.mjs +109 -0
  8. package/capabilities/oats-okf/lib/config.mjs +2 -1
  9. package/capabilities/oats-okf/lib/inspection.mjs +16 -1
  10. package/capabilities/oats-okf/lib/invocation-context.mjs +111 -0
  11. package/capabilities/oats-okf/lib/invocation-shape.mjs +135 -0
  12. package/capabilities/oats-okf/lib/io.mjs +1 -1
  13. package/capabilities/oats-okf/lib/portable-binding.mjs +199 -0
  14. package/capabilities/oats-okf/lib/source-contract.mjs +46 -0
  15. package/capabilities/oats-okf/lib/sources.mjs +123 -3
  16. package/capabilities/oats-okf/lib/stores.mjs +104 -25
  17. package/capabilities/oats-okf/lib/worker.mjs +69 -10
  18. package/capabilities/oats-okf/oats.json +35 -7
  19. package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +87 -0
  20. package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +113 -0
  21. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +23 -5
  22. package/capabilities/oats-okf/skills/okf/SKILL.md +42 -1
  23. package/docs/artifact-approvals.schema.json +7 -0
  24. package/docs/capabilities.md +4 -0
  25. package/docs/capability-manifest.schema.json +37 -66
  26. package/docs/captured-invocation-context.schema.json +7 -0
  27. package/docs/captured-resolution.schema.json +7 -0
  28. package/docs/design/2026-09-14-artifact-retention-contract.md +190 -0
  29. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +708 -0
  30. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +85 -0
  31. package/docs/design/2026-09-14-portable-souls-explainer.md +750 -0
  32. package/docs/design/2026-09-15-captured-dispatch.md +127 -0
  33. package/docs/design/2026-09-15-captured-resolution-records.md +143 -0
  34. package/docs/design/2026-09-15-package-preparation.md +100 -0
  35. package/docs/design/2026-09-15-portable-data-contract.md +121 -0
  36. package/docs/design/2026-09-15-portable-declarations.md +189 -0
  37. package/docs/design/2026-09-15-portable-souls-handoff.md +150 -0
  38. package/docs/design/2026-09-15-portable-souls-implementation.md +417 -0
  39. package/docs/design/2026-09-15-selection-lock-and-approval.md +122 -0
  40. package/docs/design/2026-09-15-source-observation.md +119 -0
  41. package/docs/design/2026-09-16-captured-admission.md +77 -0
  42. package/docs/design/2026-09-16-captured-helper-dispatch.md +105 -0
  43. package/docs/design/2026-09-16-captured-launch-inputs.md +42 -0
  44. package/docs/design/2026-09-16-command-profile-preparation.md +86 -0
  45. package/docs/design/2026-09-16-fresh-install-first-rollout.md +47 -0
  46. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +277 -0
  47. package/docs/design/2026-09-16-knowledge-capability-contract.md +61 -0
  48. package/docs/design/2026-09-16-messaging-capability-contract.md +59 -0
  49. package/docs/design/2026-09-16-portable-migration-evidence.md +156 -0
  50. package/docs/design/2026-09-16-portable-onboarding.md +177 -0
  51. package/docs/design/2026-09-16-prepare-request-transport.md +26 -0
  52. package/docs/design/2026-09-16-provider-binding-codecs.md +98 -0
  53. package/docs/design/2026-09-16-provider-binding-wire.md +247 -0
  54. package/docs/design/2026-09-17-capability-helper-input-contract.md +95 -0
  55. package/docs/design/2026-09-17-captured-backend-parity.md +53 -0
  56. package/docs/design/2026-09-17-captured-native-start.md +58 -0
  57. package/docs/design/2026-09-17-portable-boundary-hookup.md +19 -0
  58. package/docs/design/2026-09-17-portable-boundary-resources.md +52 -0
  59. package/docs/design/2026-09-17-public-captured-start.md +108 -0
  60. package/docs/design/2026-09-17-public-prepare-request.md +90 -0
  61. package/docs/design/2026-09-18-captured-pi-host.md +205 -0
  62. package/docs/design/2026-09-18-first-cut-release-checklist.md +131 -0
  63. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +60 -0
  64. package/docs/design/2026-09-20-redesign-program-board.md +70 -0
  65. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +287 -0
  66. package/docs/design/2026-09-20-workspace-onboarding-public.md +124 -0
  67. package/docs/design/README.md +42 -0
  68. package/docs/desktop-cli-api.md +5 -2
  69. package/docs/execution-capsule.schema.json +108 -0
  70. package/docs/execution-targets.md +20 -7
  71. package/docs/first-team.md +1 -1
  72. package/docs/knowledge-theory.md +353 -111
  73. package/docs/knowledge.md +10 -1
  74. package/docs/layers.md +89 -354
  75. package/docs/oats-lock-v3.schema.json +7 -0
  76. package/docs/oats-member.schema.json +38 -0
  77. package/docs/oats-workspace.schema.json +68 -0
  78. package/docs/official-marketplace.md +79 -0
  79. package/docs/packages.md +4 -0
  80. package/docs/portable.schema.json +2512 -0
  81. package/docs/provider-check-input.schema.json +7 -0
  82. package/docs/release-notes/v0.24.0.md +104 -0
  83. package/docs/release-notes/v0.24.1.md +17 -0
  84. package/docs/schedules.md +126 -14
  85. package/docs/soul.schema.json +82 -0
  86. package/docs/souls-and-instances.md +20 -7
  87. package/docs/workspace-adoption.md +285 -0
  88. package/docs/workspaces.md +154 -0
  89. package/injects/oats-portable.md +16 -0
  90. package/injects/portable-instance-boundary.md +39 -0
  91. package/injects/portable-work-directory.md +29 -0
  92. package/lib/artifact-approvals.mjs +120 -0
  93. package/lib/artifact-tree.mjs +141 -0
  94. package/lib/capability-artifacts.mjs +179 -0
  95. package/lib/capability-execution.mjs +15 -0
  96. package/lib/capability-inputs.mjs +39 -0
  97. package/lib/capability-provenance.mjs +231 -0
  98. package/lib/captured-action-shape.mjs +21 -0
  99. package/lib/captured-admission-shape.mjs +20 -0
  100. package/lib/captured-binding-file.mjs +36 -0
  101. package/lib/captured-dispatch.mjs +66 -0
  102. package/lib/captured-instance-index.mjs +277 -0
  103. package/lib/captured-invocation-context.mjs +130 -0
  104. package/lib/captured-launch-request.mjs +46 -0
  105. package/lib/captured-operation-process.mjs +15 -0
  106. package/lib/captured-pi-custody.mjs +29 -0
  107. package/lib/captured-pi-host.mjs +167 -0
  108. package/lib/captured-pi-outcome.mjs +172 -0
  109. package/lib/captured-resolutions.mjs +275 -0
  110. package/lib/captured-scaffold.mjs +87 -0
  111. package/lib/captured-selector.mjs +28 -0
  112. package/lib/captured-session-backend.mjs +52 -0
  113. package/lib/captured-source-receipt-file.mjs +72 -0
  114. package/lib/config-data.mjs +104 -0
  115. package/lib/core.mjs +961 -566
  116. package/lib/errors.mjs +7 -0
  117. package/lib/helper-injection-policy.mjs +98 -0
  118. package/lib/herdr.mjs +18 -7
  119. package/lib/instruction-composition.mjs +31 -0
  120. package/lib/legacy-lock-codec.mjs +106 -0
  121. package/lib/manifest-settings.mjs +84 -0
  122. package/lib/package-closure.mjs +48 -0
  123. package/lib/package-materialization.mjs +83 -0
  124. package/lib/pi-sdk-host.mjs +229 -0
  125. package/lib/portable-artifacts.mjs +115 -0
  126. package/lib/portable-choices.mjs +82 -0
  127. package/lib/portable-composition.mjs +136 -0
  128. package/lib/portable-digest.mjs +105 -0
  129. package/lib/portable-files.mjs +26 -0
  130. package/lib/portable-identity.mjs +40 -0
  131. package/lib/portable-lock.mjs +117 -0
  132. package/lib/portable-migration-artifacts.mjs +135 -0
  133. package/lib/portable-migration-evidence.mjs +305 -0
  134. package/lib/portable-migration-store.mjs +199 -0
  135. package/lib/portable-migration.mjs +104 -0
  136. package/lib/portable-onboarding-acceptance.mjs +66 -0
  137. package/lib/portable-onboarding-request.mjs +49 -0
  138. package/lib/portable-onboarding.mjs +249 -0
  139. package/lib/portable-package-preparation.mjs +188 -0
  140. package/lib/portable-policy.mjs +44 -0
  141. package/lib/portable-shape.mjs +35 -0
  142. package/lib/portable-soul.mjs +38 -0
  143. package/lib/portable-state.mjs +80 -0
  144. package/lib/portable-values.mjs +181 -0
  145. package/lib/prepare-composition.mjs +151 -0
  146. package/lib/prepared-bindings.mjs +78 -0
  147. package/lib/prepared-resources.mjs +127 -0
  148. package/lib/provider-binding-broker.mjs +59 -0
  149. package/lib/provider-binding-wire.mjs +110 -0
  150. package/lib/provider-binding.mjs +22 -0
  151. package/lib/repository-observation.mjs +226 -0
  152. package/lib/resolution-shape.mjs +393 -0
  153. package/lib/schedule-capsule.mjs +206 -0
  154. package/lib/schedule.mjs +259 -38
  155. package/lib/servers.mjs +15 -0
  156. package/lib/soul-constraints.mjs +40 -0
  157. package/lib/source-projection.mjs +84 -0
  158. package/lib/source-spec.mjs +189 -0
  159. package/lib/workspace-definition.mjs +126 -0
  160. package/lib/workspace-discovery.mjs +146 -0
  161. package/package-catalog.json +2 -1
  162. package/package.json +3 -2
  163. package/packages/record/lib/capture-cc.mjs +14 -6
  164. package/packages/record/lib/formats.mjs +14 -3
  165. package/packages/record/lib/native-history.mjs +277 -7
  166. package/packages/record/lib/session-snapshot.mjs +25 -5
  167. package/packages/record/lib/sessions-for-home.mjs +30 -13
  168. package/skills/oats/SKILL.md +12 -7
  169. package/skills/oats-config/SKILL.md +11 -9
  170. package/skills/oats-packages/SKILL.md +12 -8
  171. package/skills/oats-portable/SKILL.md +115 -0
  172. package/skills/oats-portable-artifacts/SKILL.md +63 -0
package/lib/schedule.mjs CHANGED
@@ -1,4 +1,5 @@
1
- /** OATS schedules v1: workspace-scoped definitions, host-owned execution.
1
+ /** OATS schedules: legacy v1 plus explicit captured-execution v2,
2
+ * workspace-scoped definitions and host-owned execution.
2
3
  *
3
4
  * A definition lives in <scope>/oats-schedules.json, where the scope is the
4
5
  * team workspace (the config level declaring the team, else the outermost
@@ -24,10 +25,12 @@ import { homedir } from "node:os";
24
25
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
25
26
  import { fileURLToPath } from "node:url";
26
27
  import { Cron } from "croner";
27
- import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
28
+ import { portableScope } from "./portable-state.mjs";
29
+ import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath, loadCapturedDispatch, prepareCapturedComposition } from "./core.mjs";
30
+ import { RECURRENCE_POLICIES, SCHEDULE_ATTEMPT_VERSION, SCHEDULE_DEFINITION_VERSION, admitExecutionTemplate, buildCommandExecutionTemplate, buildWakeExecutionTemplate, capturedDispatchAction, commandArgvWithExecutionBinding, executionContentIntegrity, validateExecutionBinding, validateExecutionCapsule, validateExecutionTemplate, validatePreparationInput } from "./schedule-capsule.mjs";
28
31
 
29
32
  export const SCHEDULE_FILE = "oats-schedules.json";
30
- export const SCHEDULE_API = 1;
33
+ export const SCHEDULE_API = 2;
31
34
  const ID_RE = /^[a-z0-9-]{1,40}$/;
32
35
  const RUNTIMES = new Set(["pi", "claude", "codex"]);
33
36
  const BACKENDS = new Set(["tmux", "herdr"]);
@@ -38,6 +41,26 @@ const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
38
41
  export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
39
42
  /** A stored definition that is not even shaped like one (a hand edit): it
40
43
  * is never observed or executed, and is reported invalid on that job only. */
44
+ export function scheduleExecutionStatus(def, intent, lock) {
45
+ const versioned = def?.definitionVersion !== undefined || def?.recurrencePolicy !== undefined || def?.execution !== undefined || def?.preparation !== undefined;
46
+ let result;
47
+ if (!versioned) result = { kind: "legacy", capture: "unknown", migrationRequired: true };
48
+ else {
49
+ const invalid = shapeError(def);
50
+ if (invalid) result = { kind: "invalid", capture: "unknown", migrationRequired: true, reason: invalid };
51
+ else if (def.recurrencePolicy === "prepare-on-tick") result = { kind: "prepare-on-tick", capture: "not-captured", migrationRequired: false, reason: "explicit preparation runs at admission" };
52
+ else result = { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: def.execution.schemaVersion, contentIntegrity: executionContentIntegrity(def.execution), resolution: def.execution.resolution };
53
+ }
54
+ if (!intent) return result;
55
+ try {
56
+ const execution = validateAttemptExecution(intent, lock);
57
+ return { ...result, intent: execution ? { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: intent.schemaVersion, executionId: execution.executionId, resolution: execution.resolution }
58
+ : { kind: "legacy", capture: "unknown", migrationRequired: true } };
59
+ } catch (error) {
60
+ return { ...result, intent: { kind: "invalid", capture: "unknown", migrationRequired: true, reason: error.message, errorCode: error.code } };
61
+ }
62
+ }
63
+
41
64
  export function shapeError(def) {
42
65
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
43
66
  if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
@@ -46,6 +69,29 @@ export function shapeError(def) {
46
69
  if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
47
70
  if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
48
71
  if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
72
+ if (def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined) {
73
+ if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) return `definitionVersion must be ${SCHEDULE_DEFINITION_VERSION}`;
74
+ if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) return "recurrencePolicy must be capture or prepare-on-tick";
75
+ if (!["command", "wake"].includes(def.kind)) return "versioned execution currently supports command and captured wake definitions only";
76
+ if (def.kind === "wake" && def.recurrencePolicy !== "capture") return "wake definitions require capture policy";
77
+ if (def.recurrencePolicy === "capture") {
78
+ if (def.preparation !== undefined) return "capture policy cannot carry prepare-on-tick input";
79
+ try {
80
+ validateExecutionTemplate(def.execution);
81
+ const expected = def.kind === "command"
82
+ ? buildCommandExecutionTemplate({ cwd: def.cwd, argv: def.argv, inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman })
83
+ : buildWakeExecutionTemplate({ home: def.home, message: def.message,
84
+ executionBinding: { schemaVersion: 1, deployment: def.execution.deployment, resolution: def.execution.resolution },
85
+ inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman });
86
+ if (executionContentIntegrity(expected).value !== executionContentIntegrity(def.execution).value) return "execution template differs from the saved target";
87
+ } catch (error) { return `execution template is invalid: ${error.message}`; }
88
+ } else {
89
+ if (def.execution !== undefined) return "prepare-on-tick cannot carry a pinned execution template";
90
+ try { validatePreparationInput(def.preparation); } catch (error) { return `preparation input is invalid: ${error.message}`; }
91
+ try { commandArgvWithExecutionBinding(def.argv, { schemaVersion: 1, deployment: def.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } }); }
92
+ catch (error) { return `prepare-on-tick command is invalid: ${error.message}`; }
93
+ }
94
+ }
49
95
  return null;
50
96
  }
51
97
 
@@ -94,7 +140,7 @@ function writeJson(path, value) {
94
140
  }
95
141
  export function readDefinitions(ws) {
96
142
  const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
97
- if (doc.version !== 1 || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1, jobs: {}}`, { field: "file" });
143
+ if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
98
144
  return doc;
99
145
  }
100
146
  export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
@@ -238,6 +284,46 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
238
284
  if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
239
285
  out.operation = def.operation; out.home = resolve(def.home);
240
286
  } else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
287
+ const versioned = def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined;
288
+ if (versioned) {
289
+ if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("E_SCHEDULE_INVALID", `definitionVersion: ${SCHEDULE_DEFINITION_VERSION} required for captured schedules`, { field: "definitionVersion" });
290
+ if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) throw scheduleError("E_SCHEDULE_INVALID", "recurrencePolicy: capture or prepare-on-tick", { field: "recurrencePolicy" });
291
+ if (!["command", "wake"].includes(def.kind)) throw scheduleError("E_SCHEDULE_INVALID", "versioned execution currently supports command and captured wake jobs; spawn and operation await their captured consumer adapters", { field: "recurrencePolicy" });
292
+ if (def.kind === "wake" && def.recurrencePolicy !== "capture") throw scheduleError("E_SCHEDULE_INVALID", "versioned wake jobs require capture policy", { field: "recurrencePolicy" });
293
+ out.definitionVersion = SCHEDULE_DEFINITION_VERSION;
294
+ out.recurrencePolicy = def.recurrencePolicy;
295
+ if (def.recurrencePolicy === "capture") {
296
+ if (def.preparation !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "preparation belongs only to prepare-on-tick", { field: "preparation" });
297
+ let execution;
298
+ try {
299
+ const responsibleHuman = Object.hasOwn(def, "responsibleHuman") ? def.responsibleHuman : def.execution?.responsibleHuman;
300
+ if (responsibleHuman === undefined) throw new Error("responsibleHuman must be explicit for captured execution (null means messaging disabled)");
301
+ if (def.kind === "command") execution = buildCommandExecutionTemplate({ cwd: out.cwd, argv: out.argv,
302
+ inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
303
+ else {
304
+ const meta = readJson(join(out.home, "instance.json"), null);
305
+ if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding", { field: "executionBinding" });
306
+ validateExecutionBinding(meta.executionBinding);
307
+ execution = buildWakeExecutionTemplate({ home: out.home, message: out.message, executionBinding: meta.executionBinding,
308
+ inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
309
+ }
310
+ if (def.execution !== undefined) {
311
+ validateExecutionTemplate(def.execution);
312
+ if (executionContentIntegrity(def.execution).value !== executionContentIntegrity(execution).value) throw new Error("template differs from target, binding, inputs or responsible human");
313
+ }
314
+ } catch (error) {
315
+ if (error.code === "migration-required") throw error;
316
+ throw scheduleError("E_SCHEDULE_INVALID", `execution: ${error.message}`, { field: "execution" });
317
+ }
318
+ out.execution = execution;
319
+ } else {
320
+ if (def.execution !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "execution: prepare-on-tick captures only when a future tick is admitted", { field: "execution" });
321
+ try {
322
+ out.preparation = validatePreparationInput(def.preparation);
323
+ commandArgvWithExecutionBinding(out.argv, { schemaVersion: 1, deployment: out.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } });
324
+ } catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `preparation: ${error.message}`, { field: "preparation" }); }
325
+ }
326
+ }
241
327
  return out;
242
328
  }
243
329
 
@@ -305,6 +391,14 @@ export function findHomesInScope(ws, instance) {
305
391
  const homes = new Set();
306
392
  for (const root of scopeRoots(ws)) {
307
393
  try { for (const h of findInstanceHomes(root, instance)) homes.add(resolve(h.home || h)); } catch { /* unreadable root */ }
394
+ // Durable homes remain discoverable after their source soul definition is
395
+ // removed. The live-soul index is a convenience, never reconciliation
396
+ // authority for an already admitted execution.
397
+ if (existsSync(root)) for (const e of readdirSync(root, { withFileTypes: true })) {
398
+ if (!e.isDirectory()) continue;
399
+ const h = join(root, e.name, "instances", instance);
400
+ if (existsSync(join(h, "instance.json"))) homes.add(resolve(h));
401
+ }
308
402
  const local = join(dirname(root), "local-agents");
309
403
  if (existsSync(local)) for (const e of readdirSync(local, { withFileTypes: true })) { if (!e.isDirectory()) continue; const h = join(local, e.name, "instances", instance); if (existsSync(join(h, "instance.json"))) homes.add(resolve(h)); }
310
404
  }
@@ -361,7 +455,7 @@ function launchCommand(ws, def, io) {
361
455
  // caller's frozen capability snapshot (including legacy OATS_HOME).
362
456
  // Keep host configuration such as OATS_HOME_DIR and credentials intact.
363
457
  for (const key of [...RESERVED_LAUNCH_ENV,
364
- "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
458
+ "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
365
459
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
366
460
  "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
367
461
  "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
@@ -392,6 +486,72 @@ function launchCommand(ws, def, io) {
392
486
  }
393
487
  return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
394
488
  }
489
+
490
+ /** Verify an immutable capsule and its current exact-artifact authorization
491
+ * before the scheduler acquires a slot or invokes a child. Legacy definitions
492
+ * retain their old release boundary; prepare-on-tick remains fail-closed until
493
+ * the preparation owner supplies its transaction API. */
494
+ function homeExecutionBinding(home) {
495
+ const meta = readJson(join(home, "instance.json"), null);
496
+ if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding");
497
+ try { return validateExecutionBinding(meta.executionBinding); }
498
+ catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `wake target executionBinding is invalid: ${error.message}`); }
499
+ }
500
+ function validateAttemptExecution(attempt, lock) {
501
+ if (attempt?.execution === undefined) {
502
+ if (attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", "versioned scheduled attempt has no captured execution");
503
+ if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "captured job lock has no matching admitted capsule");
504
+ return undefined;
505
+ }
506
+ if (attempt.schemaVersion !== SCHEDULE_ATTEMPT_VERSION) throw scheduleError("migration-required", "scheduled attempt uses an unsupported version");
507
+ const execution = validateExecutionCapsule(attempt.execution);
508
+ if (lock && lock.executionId !== execution.executionId) throw scheduleError("E_SCHEDULE_INVALID", "job lock does not name the admitted execution intent");
509
+ return execution;
510
+ }
511
+ function validateActiveExecution(js, lock) {
512
+ if (lock?.unreadable) throw scheduleError("E_SCHEDULE_INVALID", "job lock custody is unreadable");
513
+ if (js.attempt) return validateAttemptExecution(js.attempt, lock);
514
+ if (lock && (js.lastRun?.execution || lock.executionId !== undefined)) {
515
+ return validateAttemptExecution(js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}, lock);
516
+ }
517
+ }
518
+ function receiptMatchesExecution(receipt, execution) {
519
+ return !!receipt?.execution && receipt.execution.executionId === execution.executionId
520
+ && executionContentIntegrity(validateExecutionCapsule(receipt.execution)).value === executionContentIntegrity(execution).value;
521
+ }
522
+ function admitExecution(def, io, { createIntent = true } = {}) {
523
+ if (def.definitionVersion === undefined) return undefined;
524
+ if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("migration-required", "scheduled definition uses an unsupported execution version");
525
+ let execution;
526
+ if (def.recurrencePolicy === "prepare-on-tick") {
527
+ if (!createIntent) return undefined; // dry-run never prepares or publishes
528
+ const prepare = io && Object.hasOwn(io, "prepare") ? io.prepare : prepareCapturedComposition;
529
+ if (typeof prepare !== "function") throw scheduleError("migration-required", "prepare-on-tick needs the preparation transaction adapter before this tick can be admitted");
530
+ const request = validatePreparationInput(def.preparation);
531
+ const requestedDeployment = portableScope(request.deployment); // Freeze identity before preparation, including platform path aliases.
532
+ const prepared = prepare(request);
533
+ if (!prepared || typeof prepared !== "object" || !prepared.executionBinding || !Object.hasOwn(prepared, "responsibleHuman")) {
534
+ throw scheduleError("needs-configuration", "preparation did not produce a complete executionBinding and responsibleHuman", { problems: prepared?.problems || [] });
535
+ }
536
+ const binding = validateExecutionBinding(prepared.executionBinding);
537
+ let sameDeployment = false;
538
+ try { sameDeployment = portableScope(binding.deployment) === requestedDeployment && portableScope(request.deployment) === requestedDeployment; } catch { /* absent/changed scope is not the admitted deployment */ }
539
+ if (!sameDeployment) throw scheduleError("E_SCHEDULE_INVALID", "prepared executionBinding changed the requested deployment");
540
+ binding.deployment = requestedDeployment; // Persist canonical authority, not a retargetable spelling.
541
+ if (prepared.resolution && prepared.resolution.id !== binding.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "prepared resolution differs from executionBinding");
542
+ const argv = commandArgvWithExecutionBinding(def.argv, binding);
543
+ execution = buildCommandExecutionTemplate({ cwd: def.cwd, argv, inputRefs: prepared.inputRefs ?? {}, responsibleHuman: prepared.responsibleHuman });
544
+ } else execution = validateExecutionTemplate(def.execution);
545
+ const action = capturedDispatchAction(execution);
546
+ if (action.kind === "wake") {
547
+ const binding = homeExecutionBinding(execution.target.home);
548
+ if (binding.deployment !== execution.deployment || binding.resolution.id !== execution.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "wake target executionBinding differs from its captured template");
549
+ }
550
+ const request = { deployment: execution.deployment, resolution: execution.resolution, action };
551
+ (io?.admit || loadCapturedDispatch)(request);
552
+ if (action.kind === "wake") throw scheduleError("migration-required", "captured wake lifecycle dispatch is not integrated yet");
553
+ return createIntent ? admitExecutionTemplate(execution) : undefined;
554
+ }
395
555
  /** One wake. `startIfStopped` false between due minutes (a harness that
396
556
  * keeps exiting is not restarted every minute); `canStart` false when the
397
557
  * host bound is reached (a new runtime takes a slot; a delivery does not). */
@@ -437,9 +597,10 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
437
597
  * marker) or its home is gone: a persistent home outliving its process
438
598
  * does not keep a slot. */
439
599
  function observePrevious(ws, id, def, js, io, now, { mutate }) {
440
- if (shapeError(def)) return;
441
- const lr = js.lastRun;
442
600
  const lock = jobLockInfo(ws, id);
601
+ validateActiveExecution(js, lock); // No observation may erase invalid custody.
602
+ if (js.attempt || shapeError(def)) return; // Unconfirmed effects need reconcile.
603
+ const lr = js.lastRun;
443
604
  const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
444
605
  const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
445
606
  if (!tracked || !home) return;
@@ -485,11 +646,29 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
485
646
  for (const id of order) {
486
647
  const def = defs.jobs[id];
487
648
  const js = stateOf(id);
649
+ validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
488
650
  let due, cronError;
489
651
  const shape = shapeError(def);
490
652
  if (shape) { cronError = shape; due = false; }
491
653
  else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
492
654
  if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
655
+ // A captured wake is bound to the target home's exact executionBinding,
656
+ // but remains gated until lifecycle exposes captured wake dispatch. Keep
657
+ // this before both pending delivery and due-start branches so neither can
658
+ // fall through to the legacy home path.
659
+ const versionedWakeDue = def.kind === "wake" && def.definitionVersion !== undefined && !dryRun
660
+ && (def.enabled || only) && ((js.pendingWake && !only) || only || (due && js.lastAttemptedMinute !== key));
661
+ if (versionedWakeDue) {
662
+ let error;
663
+ try { admitExecution(def, io, { createIntent: false }); } catch (caught) { error = caught; }
664
+ if (error) {
665
+ if (only || due) js.lastAttemptedMinute = key;
666
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: "wake", launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
667
+ record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
668
+ continue;
669
+ }
670
+ throw scheduleError("migration-required", "captured wake unexpectedly passed its lifecycle integration gate");
671
+ }
493
672
  if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
494
673
  const dueNow = due && js.lastAttemptedMinute !== key;
495
674
  const held = !!jobLockInfo(ws, id);
@@ -507,12 +686,24 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
507
686
  if (js.lastAttemptedMinute === key && !only) continue;
508
687
  if (!due) continue;
509
688
  if (dryRun) {
510
- const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : undefined;
511
- record(id, reason ? "skipped" : "due", reason ? { reason } : {});
689
+ let admission;
690
+ if (!js.attempt && !jobLockInfo(ws, id) && (def.kind === "wake" || liveLocks(wsAll) < bound)) {
691
+ try { admitExecution(def, io, { createIntent: false }); } catch (error) { admission = error; }
692
+ }
693
+ const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : admission ? `admission blocked (${admission.code || "error"}): ${admission.message}` : undefined;
694
+ record(id, reason ? (admission ? "blocked" : "skipped") : "due", reason ? { reason, ...(admission ? { errorCode: admission.code } : {}) } : {});
512
695
  continue;
513
696
  }
514
697
  js.lastAttemptedMinute = key;
515
- if (js.attempt) { record(id, "skipped", { reason: "an earlier launch has no recorded result; run oats schedule reconcile" }); js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: "launch attempt without a recorded result", scheduledFor: js.attempt.scheduledFor }; continue; }
698
+ if (js.attempt) {
699
+ let invalid;
700
+ try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
701
+ record(id, invalid ? "invalid" : "skipped", { reason: invalid ? `admitted attempt is invalid (${invalid.code || "error"}): ${invalid.message}` : "an earlier launch has no recorded result; run oats schedule reconcile", ...(invalid ? { errorCode: invalid.code } : {}) });
702
+ if (!js.attempt.execution || receiptMatchesExecution(js.lastRun, js.attempt.execution)) {
703
+ js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : "launch attempt without a recorded result", ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor };
704
+ }
705
+ continue;
706
+ }
516
707
  if (def.kind === "wake") {
517
708
  // A delivery to a running session takes no slot; starting a stopped
518
709
  // one does, and the slot is held (the job lock) until that home ends.
@@ -527,12 +718,26 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
527
718
  }
528
719
  if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
529
720
  if (liveLocks(wsAll) >= bound) { record(id, "skipped", { reason: "host busy" }); continue; }
530
- if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString() })) { record(id, "skipped", { reason: "still running" }); continue; }
531
- js.attempt = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString() };
721
+ let execution;
722
+ try { execution = admitExecution(def, io); }
723
+ catch (error) {
724
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: def.kind, launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
725
+ record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
726
+ continue;
727
+ }
728
+ // Persist the admitted authority before even reserving the launch slot.
729
+ // A crash in the following gap leaves an inspectable unresolved attempt,
730
+ // never an ownerless lock whose intended software cannot be recovered.
731
+ js.attempt = { ...(execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString(), ...(execution ? { execution } : {}) };
732
+ writeState(ws, st);
733
+ if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), ...(execution ? { executionId: execution.executionId } : {}) })) {
734
+ delete js.attempt; writeState(ws, st); record(id, "skipped", { reason: "still running" }); continue;
735
+ }
532
736
  js.lastLaunchedAt = now.toISOString();
533
737
  writeState(ws, st);
534
738
  let run;
535
- try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
739
+ const admitted = execution ? { ...def, cwd: execution.target.cwd, argv: execution.target.argv } : def;
740
+ try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : admitted, io); }
536
741
  catch (e) {
537
742
  // spawnInstance compensates its own failures, but its rollback can be
538
743
  // INCOMPLETE (a pane it could not stop, a quarantined home it kept):
@@ -542,13 +747,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
542
747
  }
543
748
  if (run.unconfirmed) {
544
749
  // Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
545
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
750
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome: "unknown" };
546
751
  record(id, "unknown", { error: run.error });
547
752
  continue;
548
753
  }
549
754
  delete js.attempt;
550
755
  const outcome = run.launched ? "launched" : "launch-failed";
551
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
756
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome };
552
757
  if (!run.launched || !run.home) releaseJobLock(ws, id);
553
758
  record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
554
759
  }
@@ -621,29 +826,40 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
621
826
  return withHostLock(() => {
622
827
  const st = readState(ws);
623
828
  const js = st.jobs[id] || (st.jobs[id] = {});
624
- const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt } : undefined);
829
+ const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { ...(js.lastRun.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt, ...(js.lastRun.execution ? { execution: js.lastRun.execution } : {}) } : undefined);
625
830
  if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
831
+ try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
832
+ catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `admitted execution capsule is invalid: ${error.message}`); }
833
+ const attemptKind = attempt.execution?.action.kind || def.kind;
626
834
  let adopted, ambiguous;
627
- if (def.kind === "spawn" && attempt.scheduledFor) {
835
+ if (attemptKind === "spawn" && attempt.scheduledFor) {
628
836
  const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
629
837
  const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
630
838
  if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
631
839
  else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
632
- } else if (def.kind === "command" || def.kind === "operation") {
633
- const named = js.lastRun?.instance ? findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home })) : [];
840
+ } else if (attemptKind === "command" || attemptKind === "operation") {
841
+ let named = [];
842
+ if (attempt.execution) {
843
+ // Only this admission's receipt may establish effects. Never borrow the
844
+ // previous run's name or consult current-config roster discovery.
845
+ if (receiptMatchesExecution(js.lastRun, attempt.execution) && typeof js.lastRun.home === "string" && isAbsolute(js.lastRun.home)) {
846
+ const home = resolve(js.lastRun.home), meta = readJson(join(home, "instance.json"), null);
847
+ if (meta?.instance === js.lastRun.instance && typeof meta.home === "string" && resolve(meta.home) === home) named = [{ instance: meta.instance, home }];
848
+ }
849
+ } else if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
634
850
  if (named.length === 1) adopted = named[0];
635
851
  else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
636
- else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven absent from here (its answer named no instance); check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
852
+ else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven from a matching admitted receipt; check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
637
853
  }
638
854
  if (ambiguous && !clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
639
855
  if (ambiguous) adopted = undefined;
640
856
  delete js.attempt;
641
857
  if (adopted) {
642
858
  const seen = observeHome(adopted.home, io);
643
- js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: def.kind, launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
644
- if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
859
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, ...(attempt.execution ? { execution: attempt.execution } : {}), launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
860
+ if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true, ...(attempt.execution ? { executionId: attempt.execution.executionId } : {}) }); } else releaseJobLock(ws, id);
645
861
  } else {
646
- js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: def.kind, launched: false, outcome: "launch-failed", error: clear ? "cleared by the operator after checking by hand" : "no instance found in the scope's roster for the unrecorded attempt" };
862
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, ...(attempt.execution ? { execution: attempt.execution } : {}), launched: false, outcome: "launch-failed", error: clear ? "cleared by the operator after checking by hand" : "no instance found in the scope's roster for the unrecorded attempt" };
647
863
  releaseJobLock(ws, id);
648
864
  }
649
865
  writeState(ws, st);
@@ -659,7 +875,9 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
659
875
  if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
660
876
  const js = st.jobs[id] || {};
661
877
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
662
- return { ...def, nextRun, lastRun: js.lastRun || null, running: !!jobLockInfo(ws, id), ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
878
+ const lock = jobLockInfo(ws, id);
879
+ const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
880
+ return { ...def, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
663
881
  }
664
882
  export function listSchedules(ws, io, { now = new Date() } = {}) {
665
883
  const defs = readDefinitions(ws), st = readState(ws);
@@ -670,6 +888,8 @@ export function addSchedule(ws, spec, io) {
670
888
  return withScopeLock(ws, () => {
671
889
  const defs = readDefinitions(ws);
672
890
  if (defs.jobs[def.id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${def.id} already exists in ${ws}`);
891
+ if (defs.version === 2 && def.definitionVersion === undefined) throw scheduleError("migration-required", "version 2 schedule files require an explicit recurrence policy for new definitions");
892
+ if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
673
893
  const now = new Date().toISOString();
674
894
  defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
675
895
  writeDefinitions(ws, defs);
@@ -685,9 +905,11 @@ export function updateSchedule(ws, id, spec, io) {
685
905
  return withHostLock(() => withScopeLock(ws, () => {
686
906
  const defs = readDefinitions(ws);
687
907
  if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
688
- const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation]);
908
+ if (defs.jobs[id].definitionVersion !== undefined && def.definitionVersion === undefined) throw scheduleError("migration-required", "a versioned schedule definition cannot be downgraded to legacy execution");
909
+ if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
910
+ const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation, d.definitionVersion, d.recurrencePolicy, d.execution, d.preparation]);
689
911
  const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
690
- if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its kind, agent, agentsRoot, repo, purpose, home, cwd and argv cannot change until it ends (cron, tz, task, message, runtime, model and enabled can)`);
912
+ if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its execution identity, kind, target and captured policy cannot change until it ends (cron, tz and enabled can)`);
691
913
  defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
692
914
  writeDefinitions(ws, defs);
693
915
  return describe(ws, id, io);
@@ -708,27 +930,26 @@ export function removeSchedule(ws, id, { force = false } = {}) {
708
930
  return withHostLock(() => withScopeLock(ws, () => {
709
931
  const defs = readDefinitions(ws);
710
932
  if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
711
- if (jobLockInfo(ws, id) && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running; wait for it to end, or --force forgets the job without stopping anything`);
933
+ const st = readState(ws), job = st.jobs[id];
934
+ if ((jobLockInfo(ws, id) || job?.attempt || job?.lastRun?.outcome === "unknown") && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has unresolved effects; reconcile first, or --force forgets the job without stopping anything`);
712
935
  delete defs.jobs[id];
713
936
  writeDefinitions(ws, defs);
714
- const st = readState(ws); delete st.jobs[id]; writeState(ws, st);
937
+ delete st.jobs[id]; writeState(ws, st);
715
938
  releaseJobLock(ws, id);
716
939
  return { removed: id };
717
940
  }));
718
941
  }
719
942
  /** Save the wake job for a freshly spawned instance (id wake-<instance>). */
720
- export function saveWakeForHome(ws, { instance, home, wake }) {
943
+ export function saveWakeForHome(ws, { instance, home, wake, executionBinding, responsibleHuman }) {
721
944
  const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
722
945
  const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
723
- const def = validateDefinition(ws, spec);
724
- return withScopeLock(ws, () => {
725
- const defs = readDefinitions(ws);
726
- if (defs.jobs[id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${id} already exists in ${ws}`);
727
- const now = new Date().toISOString();
728
- defs.jobs[id] = { ...def, createdAt: now, updatedAt: now };
729
- writeDefinitions(ws, defs);
730
- return describe(ws, id);
731
- });
946
+ if (executionBinding !== undefined || responsibleHuman !== undefined) {
947
+ if (executionBinding === undefined || responsibleHuman === undefined) throw scheduleError("needs-configuration", "captured wake creation needs both executionBinding and responsibleHuman");
948
+ const binding = validateExecutionBinding(executionBinding), actual = homeExecutionBinding(home);
949
+ if (binding.deployment !== actual.deployment || binding.resolution.id !== actual.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "spawn result executionBinding differs from the new home");
950
+ Object.assign(spec, { definitionVersion: SCHEDULE_DEFINITION_VERSION, recurrencePolicy: "capture", responsibleHuman });
951
+ }
952
+ return addSchedule(ws, spec); // Same whole-file version/policy gate as manual add.
732
953
  }
733
954
  /** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
734
955
  export function removeWakeForHome(ws, home) {
package/lib/servers.mjs CHANGED
@@ -21,6 +21,8 @@ import { createHash } from "node:crypto";
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { join, resolve } from "node:path";
24
+ import { parseStrictJson } from "./portable-values.mjs";
25
+ import { capturedSelector } from "./captured-selector.mjs";
24
26
 
25
27
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
26
28
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -233,6 +235,7 @@ export function checkRemote(target, io = {}) {
233
235
  remote: list("remote", []),
234
236
  features: list("features", []),
235
237
  operationsApi: probe.operationsApi === 1 ? 1 : null,
238
+ scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
236
239
  advertised: Array.isArray(probe.runtimes),
237
240
  };
238
241
  }
@@ -761,6 +764,18 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
761
764
  const target = targetOf(server);
762
765
  const remote = checkRemote(target, io);
763
766
  if (!remote.features.includes("schedule")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise schedules; upgrade it there`);
767
+ if (["add", "update"].includes(oatsArgs[0])) {
768
+ const indexes = oatsArgs.flatMap((arg, index) => arg === "--spec-json" ? [index] : []);
769
+ if (indexes.length > 1) throw serverError("E_BAD_ARGS", "remote schedule spec must be unambiguous");
770
+ let versioned = oatsArgs.includes("--file"); // Cannot classify remote file bytes here.
771
+ if (indexes.length) {
772
+ const spec = parseStrictJson(oatsArgs[indexes[0] + 1]);
773
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw serverError("E_BAD_ARGS", "remote schedule spec must be an object");
774
+ versioned ||= ["definitionVersion", "recurrencePolicy", "execution", "preparation"].some(key => Object.hasOwn(spec, key));
775
+ if (Array.isArray(spec.argv)) versioned ||= !!capturedSelector(spec.argv.slice(1), {});
776
+ }
777
+ if (versioned && remote.scheduleApi !== 2) throw serverError("E_REMOTE_INCOMPATIBLE", "captured schedule mutation requires advertised scheduleApi 2; no request was forwarded");
778
+ }
764
779
  const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
765
780
  const { envelope, stderr } = runRemote(target, args, io);
766
781
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId } } : envelope, stderr, target };
@@ -0,0 +1,40 @@
1
+ /** Source-derived hard field constraints, shared by preparation and verification.
2
+ * No defaults are resolved here and no source/artifact is acquired. */
3
+ import { invalidShape, pointerKey } from "./portable-shape.mjs";
4
+ import { validateRepoAnchor } from "./source-spec.mjs";
5
+ import { FUNDAMENTAL_SLOTS } from "./portable-policy.mjs";
6
+
7
+ export const capabilityChoiceKey = (id) => `/capabilities/${pointerKey(id)}`;
8
+ export const settingChoiceKey = (id, name) => `/settings/${pointerKey(id)}/${pointerKey(name)}`;
9
+ export const layerChoiceKey = (slot) => `/layers/${slot}`;
10
+
11
+ /** A repo: relation belongs to the declaring source, not the consumer's cwd or
12
+ * another authority's identically spelled relative path. Operator inputs need
13
+ * an explicit source context because their origin is not a repository document. */
14
+ export function sourceSelectionValue(id, parsed, pointer, sourceContext) {
15
+ const source = parsed.sources[`${pointer}/source`];
16
+ if (!source) invalidShape(`${pointer}/source`, "missing normalized selection source");
17
+ if (source.kind !== "repo") return { capability: id, source };
18
+ const anchor = sourceContext ?? parsed.origins[`${pointer}/source`]?.document;
19
+ if (!anchor || typeof anchor.source !== "string") invalidShape(`${pointer}/source`, "repo source needs an explicit declaring repository snapshot", "needs-configuration");
20
+ const snapshot = validateRepoAnchor({ source: anchor.source, revision: anchor.revision });
21
+ return { capability: id, source: { ...source, anchor: snapshot } };
22
+ }
23
+
24
+ export function soulConstraints(parsed) {
25
+ const result = [], source = parsed.declaration.requires ?? {};
26
+ const origin = (pointer) => ({ ...parsed.origins[pointer], kind: "soul-requirement" });
27
+ const selection = (id, item, pointer, key) => {
28
+ result.push({ key, kind: "equals", value: sourceSelectionValue(id, parsed, pointer), origin: origin(pointer) });
29
+ for (const [name, value] of Object.entries(item.settings ?? {})) {
30
+ result.push({ key: settingChoiceKey(id, name), kind: "equals", value, origin: origin(`${pointer}/settings/${pointerKey(name)}`) });
31
+ }
32
+ };
33
+ for (const [id, item] of Object.entries(source.capabilities ?? {})) selection(id, item, `/requires/capabilities/${pointerKey(id)}`, capabilityChoiceKey(id));
34
+ for (const slot of FUNDAMENTAL_SLOTS) {
35
+ if (!Object.hasOwn(source, slot)) continue;
36
+ if (source[slot] === "any") result.push({ key: layerChoiceKey(slot), kind: "required", origin: origin(`/requires/${slot}`) });
37
+ else selection(source[slot].capability, source[slot], `/requires/${slot}`, layerChoiceKey(slot));
38
+ }
39
+ return result;
40
+ }