@awebai/oats 0.25.9 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +8 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-review/oats.json +3 -2
  20. package/docs/capabilities.md +218 -47
  21. package/docs/capability-manifest.schema.json +13 -4
  22. package/docs/configuration.md +17 -5
  23. package/docs/conventions.md +16 -26
  24. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  25. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  26. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  27. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  28. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  29. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  30. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  31. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  32. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
  33. package/docs/design/2026-09-24-phase-d-plan.md +57 -0
  34. package/docs/design/2026-09-25-teams-contract.md +226 -0
  35. package/docs/design/README.md +3 -3
  36. package/docs/design/launch-configurations.md +20 -16
  37. package/docs/design/operations-contract.md +27 -10
  38. package/docs/desktop-cli-api.md +537 -261
  39. package/docs/desktop-instance-start.md +1 -1
  40. package/docs/desktop.md +7 -13
  41. package/docs/execution-targets.md +16 -18
  42. package/docs/first-team.md +14 -17
  43. package/docs/implementation.md +28 -59
  44. package/docs/integrations.md +64 -33
  45. package/docs/knowledge-capability-authoring.md +1 -1
  46. package/docs/knowledge-reference/package-craft.md +10 -8
  47. package/docs/knowledge-theory.md +1 -1
  48. package/docs/knowledge.md +10 -11
  49. package/docs/layers.md +16 -17
  50. package/docs/oats-local.schema.json +29 -1
  51. package/docs/oats-membership.schema.json +5 -3
  52. package/docs/oats-package.schema.json +2 -2
  53. package/docs/oats-workspace.schema.json +1 -1
  54. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  55. package/docs/packages.md +75 -52
  56. package/docs/release-notes/v0.22.0.md +1 -1
  57. package/docs/release-notes/v0.23.1.md +1 -1
  58. package/docs/release-notes/v0.26.0.md +670 -0
  59. package/docs/schedules.md +48 -126
  60. package/docs/soul.schema.json +11 -4
  61. package/docs/souls-and-instances.md +56 -43
  62. package/docs/workspaces.md +80 -58
  63. package/injects/instance-boundary.md +1 -1
  64. package/injects/work-attached.md +1 -1
  65. package/injects/work-workspace.md +2 -2
  66. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  67. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  68. package/lib/capability-contract.mjs +110 -0
  69. package/lib/config-data.mjs +2 -2
  70. package/lib/core.mjs +700 -4824
  71. package/lib/digest.mjs +12 -0
  72. package/lib/instance-inspect.mjs +396 -0
  73. package/lib/instance-lifecycle.mjs +3 -4
  74. package/lib/instance-resolution.mjs +212 -26
  75. package/lib/instruction-composition.mjs +0 -20
  76. package/lib/materialize.mjs +6 -4
  77. package/lib/operator-dispatch.mjs +33 -13
  78. package/lib/packages.mjs +25 -190
  79. package/lib/provider-binding.mjs +4 -2
  80. package/lib/provider-reasons.mjs +3 -68
  81. package/lib/resolve.mjs +204 -68
  82. package/lib/schedule.mjs +97 -272
  83. package/lib/servers.mjs +13 -13
  84. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  85. package/lib/tree-copy.mjs +44 -0
  86. package/lib/workspace.mjs +125 -20
  87. package/package-catalog.json +6 -6
  88. package/package.json +1 -1
  89. package/skills/integration-authoring/SKILL.md +48 -40
  90. package/skills/oats-getting-started/SKILL.md +105 -110
  91. package/skills/oats-support/SKILL.md +2 -2
  92. package/skills/soul-craft/SKILL.md +13 -6
  93. package/bin/oats-pi-sdk-host.mjs +0 -17
  94. package/docs/2026-09-03-architecture-proposal.md +0 -642
  95. package/docs/artifact-approvals.schema.json +0 -7
  96. package/docs/captured-invocation-context.schema.json +0 -7
  97. package/docs/captured-resolution.schema.json +0 -7
  98. package/docs/design/package-engine-contract.md +0 -813
  99. package/docs/design/package-runtime-api.md +0 -588
  100. package/docs/desktop-succession.md +0 -57
  101. package/docs/execution-capsule.schema.json +0 -108
  102. package/docs/first-team-demo.md +0 -92
  103. package/docs/knowledge-migration.md +0 -147
  104. package/docs/migration-from-oas.md +0 -103
  105. package/docs/oats-config.schema.json +0 -172
  106. package/docs/oats-lock-v3.schema.json +0 -7
  107. package/docs/oats-lock.schema.json +0 -175
  108. package/docs/operating-team-migration.md +0 -470
  109. package/docs/portable.schema.json +0 -2512
  110. package/docs/provider-check-input.schema.json +0 -7
  111. package/docs/rebuild-to-v2.md +0 -511
  112. package/docs/workspace-adoption.md +0 -74
  113. package/injects/framework-workspace.md +0 -7
  114. package/injects/local-soul.md +0 -19
  115. package/injects/oats-portable.md +0 -20
  116. package/injects/oats.md +0 -11
  117. package/injects/portable-instance-boundary.md +0 -39
  118. package/injects/portable-work-directory.md +0 -29
  119. package/lib/artifact-approvals.mjs +0 -120
  120. package/lib/artifact-tree.mjs +0 -141
  121. package/lib/capability-artifacts.mjs +0 -179
  122. package/lib/capability-execution.mjs +0 -15
  123. package/lib/capability-inputs.mjs +0 -39
  124. package/lib/capability-provenance.mjs +0 -231
  125. package/lib/captured-action-shape.mjs +0 -21
  126. package/lib/captured-admission-shape.mjs +0 -20
  127. package/lib/captured-binding-file.mjs +0 -36
  128. package/lib/captured-dispatch.mjs +0 -66
  129. package/lib/captured-instance-index.mjs +0 -277
  130. package/lib/captured-invocation-context.mjs +0 -130
  131. package/lib/captured-launch-request.mjs +0 -66
  132. package/lib/captured-operation-process.mjs +0 -15
  133. package/lib/captured-pi-custody.mjs +0 -29
  134. package/lib/captured-pi-host.mjs +0 -167
  135. package/lib/captured-pi-outcome.mjs +0 -172
  136. package/lib/captured-resolutions.mjs +0 -275
  137. package/lib/captured-scaffold.mjs +0 -87
  138. package/lib/captured-selector.mjs +0 -28
  139. package/lib/captured-session-backend.mjs +0 -52
  140. package/lib/captured-source-receipt-file.mjs +0 -72
  141. package/lib/helper-injection-policy.mjs +0 -104
  142. package/lib/legacy-lock-codec.mjs +0 -106
  143. package/lib/manifest-settings.mjs +0 -84
  144. package/lib/package-closure.mjs +0 -48
  145. package/lib/package-materialization.mjs +0 -83
  146. package/lib/pi-sdk-host.mjs +0 -229
  147. package/lib/portable-artifacts.mjs +0 -115
  148. package/lib/portable-choices.mjs +0 -82
  149. package/lib/portable-composition.mjs +0 -136
  150. package/lib/portable-digest.mjs +0 -105
  151. package/lib/portable-identity.mjs +0 -40
  152. package/lib/portable-lock.mjs +0 -117
  153. package/lib/portable-onboarding-request.mjs +0 -49
  154. package/lib/portable-onboarding.mjs +0 -256
  155. package/lib/portable-package-preparation.mjs +0 -188
  156. package/lib/portable-policy.mjs +0 -44
  157. package/lib/portable-soul.mjs +0 -42
  158. package/lib/portable-state.mjs +0 -80
  159. package/lib/prepare-composition.mjs +0 -170
  160. package/lib/prepared-bindings.mjs +0 -92
  161. package/lib/prepared-resources.mjs +0 -127
  162. package/lib/provider-binding-broker.mjs +0 -65
  163. package/lib/provider-binding-wire.mjs +0 -116
  164. package/lib/readiness.mjs +0 -225
  165. package/lib/repository-observation.mjs +0 -226
  166. package/lib/resolution-shape.mjs +0 -393
  167. package/lib/schedule-capsule.mjs +0 -206
  168. package/lib/soul-constraints.mjs +0 -40
  169. package/lib/source-projection.mjs +0 -84
  170. package/lib/source-spec.mjs +0 -189
  171. package/lib/workspace-definition.mjs +0 -126
  172. package/lib/workspace-discovery.mjs +0 -146
  173. package/skills/oats/SKILL.md +0 -162
  174. package/skills/oats-config/SKILL.md +0 -164
  175. package/skills/oats-packages/SKILL.md +0 -184
  176. package/skills/oats-portable/SKILL.md +0 -115
  177. package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/schedule.mjs CHANGED
@@ -1,9 +1,10 @@
1
- /** OATS schedules: legacy v1 plus explicit captured-execution v2,
2
- * workspace-scoped definitions and host-owned execution.
1
+ /** OATS schedules: workspace-scoped definitions and host-owned execution.
2
+ * (The captured-execution definitions of 0.24–0.25 were removed in 0.26: a
3
+ * stored one is reported invalid on its job and never runs.)
3
4
  *
4
5
  * A definition lives in <scope>/oats-schedules.json, where the scope is the
5
- * team workspace (the config level declaring the team, else the outermost
6
- * oats-config.yaml level), and is evaluated by the host that holds that
6
+ * deployment directory (the one holding oats-local.yaml, found walking up;
7
+ * see scheduleScopeOf), and is evaluated by the host that holds that
7
8
  * scope: one host registry, one host lock, one host timer that runs
8
9
  * `oats schedule tick --host` once a minute. The tick is a short-lived
9
10
  * process; there is no daemon, no queue and no retry. Only the current
@@ -19,17 +20,15 @@
19
20
  * delivered, started, skipped for wake) and never claim that a task
20
21
  * succeeded. A launch whose side effects cannot be confirmed stays
21
22
  * `unknown` with its slot held until `reconcile` proves what happened. */
22
- import { execFileSync, spawnSync } from "node:child_process";
23
+ import { spawnSync } from "node:child_process";
23
24
  import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
25
  import { createHash } from "node:crypto";
25
26
  import { homedir } from "node:os";
26
27
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
27
28
  import { fileURLToPath } from "node:url";
28
29
  import { Cron } from "croner";
29
- import { portableScope } from "./portable-state.mjs";
30
30
  import { loadLocal } from "./workspace.mjs";
31
- import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath, loadCapturedDispatch, prepareCapturedComposition } from "./core.mjs";
32
- import { RECURRENCE_POLICIES, SCHEDULE_ATTEMPT_VERSION, SCHEDULE_DEFINITION_VERSION, admitExecutionTemplate, buildCommandExecutionTemplate, buildWakeExecutionTemplate, capturedDispatchAction, commandArgvWithExecutionBinding, executionContentIntegrity, validateExecutionBinding, validateExecutionCapsule, validateExecutionTemplate, validatePreparationInput } from "./schedule-capsule.mjs";
31
+ import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
33
32
 
34
33
  export const SCHEDULE_FILE = "oats-schedules.json";
35
34
  export const SCHEDULE_API = 2;
@@ -41,28 +40,24 @@ const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oat
41
40
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
42
41
 
43
42
  export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
44
- /** A stored definition that is not even shaped like one (a hand edit): it
45
- * is never observed or executed, and is reported invalid on that job only. */
43
+ /** describe's `executionStatus` (a released wire, kept as it was): a definition is
44
+ * `legacy` — the only kind that runs — or `invalid` (a captured one included,
45
+ * since 0.26), and so is its unresolved attempt. */
46
46
  export function scheduleExecutionStatus(def, intent, lock) {
47
- const versioned = def?.definitionVersion !== undefined || def?.recurrencePolicy !== undefined || def?.execution !== undefined || def?.preparation !== undefined;
48
- let result;
49
- if (!versioned) result = { kind: "legacy", capture: "unknown", migrationRequired: true };
50
- else {
51
- const invalid = shapeError(def);
52
- if (invalid) result = { kind: "invalid", capture: "unknown", migrationRequired: true, reason: invalid };
53
- else if (def.recurrencePolicy === "prepare-on-tick") result = { kind: "prepare-on-tick", capture: "not-captured", migrationRequired: false, reason: "explicit preparation runs at admission" };
54
- else result = { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: def.execution.schemaVersion, contentIntegrity: executionContentIntegrity(def.execution), resolution: def.execution.resolution };
55
- }
47
+ const versioned = CAPTURED_SCHEDULE_KEYS.some((key) => def?.[key] !== undefined);
48
+ const result = versioned ? { kind: "invalid", capture: "unknown", migrationRequired: true, reason: shapeError(def) } : { kind: "legacy", capture: "unknown", migrationRequired: true };
56
49
  if (!intent) return result;
57
50
  try {
58
- const execution = validateAttemptExecution(intent, lock);
59
- return { ...result, intent: execution ? { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: intent.schemaVersion, executionId: execution.executionId, resolution: execution.resolution }
60
- : { kind: "legacy", capture: "unknown", migrationRequired: true } };
51
+ validateAttemptExecution(intent, lock);
52
+ return { ...result, intent: { kind: "legacy", capture: "unknown", migrationRequired: true } };
61
53
  } catch (error) {
62
54
  return { ...result, intent: { kind: "invalid", capture: "unknown", migrationRequired: true, reason: error.message, errorCode: error.code } };
63
55
  }
64
56
  }
65
57
 
58
+ /** The definition keys of a captured (versioned) schedule. That path was removed in 0.26. */
59
+ export const CAPTURED_SCHEDULE_KEYS = Object.freeze(["definitionVersion", "recurrencePolicy", "execution", "preparation"]);
60
+ const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/portable path was removed in 0.26); remove this job, or re-add it without definitionVersion, recurrencePolicy, execution and preparation";
66
61
  export function shapeError(def) {
67
62
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
68
63
  if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
@@ -71,54 +66,29 @@ export function shapeError(def) {
71
66
  if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
72
67
  if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
73
68
  if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
74
- if (def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined) {
75
- if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) return `definitionVersion must be ${SCHEDULE_DEFINITION_VERSION}`;
76
- if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) return "recurrencePolicy must be capture or prepare-on-tick";
77
- if (!["command", "wake"].includes(def.kind)) return "versioned execution currently supports command and captured wake definitions only";
78
- if (def.kind === "wake" && def.recurrencePolicy !== "capture") return "wake definitions require capture policy";
79
- if (def.recurrencePolicy === "capture") {
80
- if (def.preparation !== undefined) return "capture policy cannot carry prepare-on-tick input";
81
- try {
82
- validateExecutionTemplate(def.execution);
83
- const expected = def.kind === "command"
84
- ? buildCommandExecutionTemplate({ cwd: def.cwd, argv: def.argv, inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman })
85
- : buildWakeExecutionTemplate({ home: def.home, message: def.message,
86
- executionBinding: { schemaVersion: 1, deployment: def.execution.deployment, resolution: def.execution.resolution },
87
- inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman });
88
- if (executionContentIntegrity(expected).value !== executionContentIntegrity(def.execution).value) return "execution template differs from the saved target";
89
- } catch (error) { return `execution template is invalid: ${error.message}`; }
90
- } else {
91
- if (def.execution !== undefined) return "prepare-on-tick cannot carry a pinned execution template";
92
- try { validatePreparationInput(def.preparation); } catch (error) { return `preparation input is invalid: ${error.message}`; }
93
- try { commandArgvWithExecutionBinding(def.argv, { schemaVersion: 1, deployment: def.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } }); }
94
- catch (error) { return `prepare-on-tick command is invalid: ${error.message}`; }
95
- }
96
- }
69
+ // A stored captured (versioned) definition is never evaluated or run (removed in 0.26): it reads
70
+ // as invalid, migration required, on that job only.
71
+ if (CAPTURED_SCHEDULE_KEYS.some((key) => def[key] !== undefined)) return CAPTURED_SCHEDULE_REMOVED;
97
72
  return null;
98
73
  }
99
74
 
100
75
  // ---------------------------------------------------------------- scope
101
76
 
102
- /** The one schedule-owning scope for a directory: the config level that
103
- * declares the team (the deployment boundary), else the outermost level
104
- * carrying oats-config.yaml, else the workspace above the agents root. */
77
+ /** The one schedule-owning scope for a directory: the deployment, i.e. the
78
+ * directory holding the oats-local.yaml found walking up. No deployment in
79
+ * reach is E_LOCAL_MISSING; an ambient OATS_ROOT / PI_AGENTS_ROOT never
80
+ * chooses the scope (lead decision c3-5). */
105
81
  export function scheduleScopeOf(dir) {
106
- const start = resolve(dir);
107
- let chain = [];
108
- try { chain = configChain(start); } catch { chain = []; }
109
- const team = chain.find((c) => c.team);
110
- if (team?._level) return resolve(team._level);
111
- if (chain.length) return resolve(chain[chain.length - 1]._level);
112
- try { return dirname(ensureRoot(start)); } catch { return start; }
113
- }
114
- /** Every agents root the scope knows: its own, member repositories', and any
115
- * root that holds instance homes. */
116
- export function scopeRoots(scope) {
117
- const roots = new Set();
118
- try { roots.add(resolve(ensureRoot(scope))); } catch { /* a scope with no agents dir */ }
119
- try { for (const r of teamAgentRoots(scope)) roots.add(resolve(r)); } catch { /* not a team scope */ }
120
- return [...roots];
82
+ let found;
83
+ try { found = loadLocal(dir); }
84
+ catch (e) {
85
+ if (e?.code === "E_LOCAL_MISSING") throw scheduleError("E_LOCAL_MISSING", `schedules belong to a workspace deployment, and none is in reach of ${resolve(dir)} (no oats-local.yaml walking up; \`oats onboard\` creates one)`, { details: { dir: resolve(dir) } });
86
+ throw e;
87
+ }
88
+ return dirname(found.path);
121
89
  }
90
+ /** The scope's one agents root: <deployment>/agents. */
91
+ export function scopeRoots(scope) { return [resolve(scope, "agents")]; }
122
92
 
123
93
  // ------------------------------------------------------------- files
124
94
 
@@ -321,6 +291,10 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
321
291
  if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
322
292
  if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
323
293
  if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
294
+ // A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
295
+ // MAX_INSTANCE_NAME characters, so refuse here what would fail at every firing.
296
+ { const stem = out.purpose ?? id, derived = `${out.agent}-${stem}-${"0".repeat(12)}`;
297
+ if (derived.length > MAX_INSTANCE_NAME) throw scheduleError("E_SCHEDULE_INVALID", `${out.purpose !== undefined ? "purpose" : "id"}: runs would be named ${out.agent}-${stem}-<YYYYMMDDHHMM> (${derived.length} characters); instance names are at most ${MAX_INSTANCE_NAME} characters — shorten the ${out.purpose !== undefined ? "purpose" : "schedule id (or set a shorter purpose)"}`, { field: out.purpose !== undefined ? "purpose" : "id" }); }
324
298
  if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
325
299
  out.task = def.task;
326
300
  if (def.runtime !== undefined) { if (!RUNTIMES.has(def.runtime)) throw scheduleError("E_SCHEDULE_INVALID", "runtime: pi, claude or codex", { field: "runtime" }); out.runtime = def.runtime; }
@@ -337,6 +311,8 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
337
311
  if (typeof def.cwd !== "string" || !isAbsolute(def.cwd) || !inside(ws, def.cwd) || !existsSync(def.cwd)) throw scheduleError("E_SCHEDULE_INVALID", "cwd: an existing absolute directory inside the scope", { field: "cwd" });
338
312
  if (!Array.isArray(def.argv) || !def.argv.length || def.argv.some((a) => typeof a !== "string" || a.includes("\0")) || def.argv[0] !== "oats") throw scheduleError("E_SCHEDULE_INVALID", "argv: oats-only argument list starting with \"oats\"", { field: "argv" });
339
313
  if (def.argv[1] === "schedule") throw scheduleError("E_SCHEDULE_INVALID", "argv: a command job may not run oats schedule (it would wait on the host lock the tick holds)", { field: "argv" });
314
+ const selector = def.argv.slice(1, def.argv.includes("--") ? def.argv.indexOf("--") : undefined).find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
315
+ if (selector) throw scheduleError("E_SCHEDULE_INVALID", `argv: ${selector.split("=")[0]} is a captured selector (the captured/portable path was removed in 0.26) — the job would be refused at every firing`, { field: "argv" });
340
316
  out.cwd = resolve(def.cwd); out.argv = [...def.argv];
341
317
  } else if (def.kind === "wake") {
342
318
  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" });
@@ -350,61 +326,19 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
350
326
  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" });
351
327
  out.operation = def.operation; out.home = resolve(def.home);
352
328
  } else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
353
- const versioned = def.definitionVersion !== undefined || def.recurrencePolicy !== undefined || def.execution !== undefined || def.preparation !== undefined;
354
- if (versioned) {
355
- if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("E_SCHEDULE_INVALID", `definitionVersion: ${SCHEDULE_DEFINITION_VERSION} required for captured schedules`, { field: "definitionVersion" });
356
- if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) throw scheduleError("E_SCHEDULE_INVALID", "recurrencePolicy: capture or prepare-on-tick", { field: "recurrencePolicy" });
357
- 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" });
358
- if (def.kind === "wake" && def.recurrencePolicy !== "capture") throw scheduleError("E_SCHEDULE_INVALID", "versioned wake jobs require capture policy", { field: "recurrencePolicy" });
359
- out.definitionVersion = SCHEDULE_DEFINITION_VERSION;
360
- out.recurrencePolicy = def.recurrencePolicy;
361
- if (def.recurrencePolicy === "capture") {
362
- if (def.preparation !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "preparation belongs only to prepare-on-tick", { field: "preparation" });
363
- let execution;
364
- try {
365
- const responsibleHuman = Object.hasOwn(def, "responsibleHuman") ? def.responsibleHuman : def.execution?.responsibleHuman;
366
- if (responsibleHuman === undefined) throw new Error("responsibleHuman must be explicit for captured execution (null means messaging disabled)");
367
- if (def.kind === "command") execution = buildCommandExecutionTemplate({ cwd: out.cwd, argv: out.argv,
368
- inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
369
- else {
370
- const meta = readJson(join(out.home, "instance.json"), null);
371
- if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding", { field: "executionBinding" });
372
- validateExecutionBinding(meta.executionBinding);
373
- execution = buildWakeExecutionTemplate({ home: out.home, message: out.message, executionBinding: meta.executionBinding,
374
- inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
375
- }
376
- if (def.execution !== undefined) {
377
- validateExecutionTemplate(def.execution);
378
- if (executionContentIntegrity(def.execution).value !== executionContentIntegrity(execution).value) throw new Error("template differs from target, binding, inputs or responsible human");
379
- }
380
- } catch (error) {
381
- if (error.code === "migration-required") throw error;
382
- throw scheduleError("E_SCHEDULE_INVALID", `execution: ${error.message}`, { field: "execution" });
383
- }
384
- out.execution = execution;
385
- } else {
386
- if (def.execution !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "execution: prepare-on-tick captures only when a future tick is admitted", { field: "execution" });
387
- try {
388
- out.preparation = validatePreparationInput(def.preparation);
389
- commandArgvWithExecutionBinding(out.argv, { schemaVersion: 1, deployment: out.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } });
390
- } catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `preparation: ${error.message}`, { field: "preparation" }); }
391
- }
392
- }
329
+ // Captured (versioned) schedules were removed in 0.26 (lead decisions on (e), D4).
330
+ const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => def[key] !== undefined);
331
+ if (captured.length) throw scheduleError("E_SCHEDULE_INVALID", `${captured.join(", ")}: ${CAPTURED_SCHEDULE_REMOVED}`, { field: captured[0] });
393
332
  return out;
394
333
  }
395
334
 
396
- /** The soul a spawn job launches: the exact agents root the definition
397
- * names (agentsRoot, what tells same-named souls in different member
398
- * repositories apart), else the scope's own root; the same lookups `oats
399
- * spawn` uses (local and persistent souls, then capability-declared
400
- * agents). `repo` is the work repository and never selects a soul. */
335
+ /** The soul a spawn job launches, under the deployment's one agents root
336
+ * (a definition's agentsRoot, when given, must be that root). `repo` is the
337
+ * work repository and never selects a soul. */
401
338
  export function resolveScheduledAgent(ws, def) {
402
- let root;
403
- try { root = def.agentsRoot ? ensureRoot(def.agentsRoot) : ensureRoot(ws); }
404
- catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${e.message}`, { field: def.agentsRoot ? "agentsRoot" : "agent" }); }
405
- if (def.agentsRoot && resolve(root) !== resolve(def.agentsRoot)) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not an agents root (resolved to ${root})`, { field: "agentsRoot" });
406
- let agent = findAgent(root, def.agent);
407
- if (!agent) { try { agent = findCapabilityAgent(dirname(root), root, def.agent); } catch { agent = undefined; } }
339
+ const [root] = scopeRoots(ws);
340
+ if (def.agentsRoot && resolve(def.agentsRoot) !== root) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not this deployment's agents root (${root})`, { field: "agentsRoot" });
341
+ const agent = findAgent(root, def.agent);
408
342
  if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
409
343
  return { root, agent };
410
344
  }
@@ -452,7 +386,7 @@ function startupInProgress(home) {
452
386
  }
453
387
 
454
388
  /** All homes for an instance name across the scope's roster (every known
455
- * agents root and its local-agents sibling). */
389
+ * agents root). */
456
390
  export function findHomesInScope(ws, instance) {
457
391
  const homes = new Set();
458
392
  for (const root of scopeRoots(ws)) {
@@ -465,8 +399,6 @@ export function findHomesInScope(ws, instance) {
465
399
  const h = join(root, e.name, "instances", instance);
466
400
  if (existsSync(join(h, "instance.json"))) homes.add(resolve(h));
467
401
  }
468
- const local = join(dirname(root), "local-agents");
469
- 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)); }
470
402
  }
471
403
  return [...homes];
472
404
  }
@@ -490,22 +422,12 @@ function childEnv() {
490
422
  ]) delete env[key];
491
423
  return env;
492
424
  }
493
- /** Is this spawn a WORKSPACE-model spawn (module contract §5, decision 7)?
494
- * Two marks, either suffices: the deployment realizes a workspace
495
- * (`oats-local.yaml` walking up from the agents root or the scope — the same
496
- * detection `oats spawn` uses), or the soul under the agents root was copied
497
- * from a member repo by ensureWorkspaceSoul (`.oats-soul-source.json` beside
498
- * it). An unreadable oats-local.yaml is still a workspace deployment: the
499
- * spawn must go the workspace way and report E_WORKSPACE_SCHEMA, never fall
500
- * back to a classic home. Returns null for a classic (bare agents root) spawn. */
501
- export function workspaceSpawnContext(ws, root, agent) {
502
- for (const dir of [dirname(root), ws]) {
503
- try { const found = loadLocal(dir); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
504
- catch (e) { if (e?.code !== "E_LOCAL_MISSING") return { kind: "local", local: null, dir, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
505
- }
506
- const stamp = agent?._dir ? join(agent._dir, ".oats-soul-source.json") : join(root, agent?.name || "", ".oats-soul-source.json");
507
- if (agent?.name && existsSync(stamp)) return { kind: "soul-source", local: null, dir: dirname(root), stamp };
508
- return null;
425
+ /** The deployment a spawn job runs in (module contract §5, decision 7): the
426
+ * scope's oats-local.yaml. An unreadable one is reported (E_WORKSPACE_SCHEMA)
427
+ * by the spawn, never answered some other way. */
428
+ export function workspaceSpawnContext(ws) {
429
+ try { const found = loadLocal(ws); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
430
+ catch (e) { return { kind: "local", local: null, dir: ws, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
509
431
  }
510
432
  /** A workspace spawn materializes EXACTLY like `oats spawn` — prepareInstance →
511
433
  * ensureWorkspaceSoul → spawnInstanceAsync (resolve over the remotes, copy every
@@ -551,20 +473,16 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
551
473
  return { instance: res.instance, home: res.home, launched: res.launched, materialized: "workspace" };
552
474
  }
553
475
  /** Launch one spawn job. `io.spawn(root, agent, opts)` overrides the whole
554
- * launcher for tests. Otherwise a workspace deployment goes through the
555
- * kernel's own `oats spawn` (workspace materialization: `.oats/modules`,
556
- * module skills and injects — never a classic home); a bare agents root goes
557
- * through the synchronous classic spawnInstance, whose failures throw here. */
476
+ * launcher for tests. Otherwise the kernel's own `oats spawn` runs in the
477
+ * deployment (workspace materialization: `.oats/modules`, module skills and
478
+ * injects). */
558
479
  function launchSpawn(ws, def, minute, io) {
559
480
  const { root, agent } = resolveScheduledAgent(ws, def);
560
481
  const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
561
482
  const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
562
483
  let r;
563
484
  if (io?.spawn) r = io.spawn(root, agent, opts);
564
- else {
565
- const context = workspaceSpawnContext(ws, root, agent);
566
- r = context ? spawnViaCli(ws, root, agent, opts, context, io) : spawnInstance(root, agent, opts);
567
- }
485
+ else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
568
486
  const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn", ...(r.materialized ? { materialized: r.materialized } : {}) };
569
487
  if (def.wake && r.home) {
570
488
  try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
@@ -629,70 +547,17 @@ function launchCommand(ws, def, io) {
629
547
  return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home, incarnation: incarnationOfHome(home) } : {}), ...(server ? { server } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
630
548
  }
631
549
 
632
- /** Verify an immutable capsule and its current exact-artifact authorization
633
- * before the scheduler acquires a slot or invokes a child. Legacy definitions
634
- * retain their old release boundary; prepare-on-tick remains fail-closed until
635
- * the preparation owner supplies its transaction API. */
636
- function homeExecutionBinding(home) {
637
- const meta = readJson(join(home, "instance.json"), null);
638
- if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding");
639
- try { return validateExecutionBinding(meta.executionBinding); }
640
- catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `wake target executionBinding is invalid: ${error.message}`); }
641
- }
550
+ /** A captured attempt or job lock left by 0.24–0.25 (the captured path, removed in
551
+ * 0.26) is never run, adopted or released here: only removing its job clears it. */
552
+ const CAPTURED_ATTEMPT_REMOVED = "this job holds a captured schedule attempt (the captured/portable path was removed in 0.26); remove the job (oats schedule remove --force <id>)";
642
553
  function validateAttemptExecution(attempt, lock) {
643
- if (attempt?.execution === undefined) {
644
- if (attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", "versioned scheduled attempt has no captured execution");
645
- if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "captured job lock has no matching admitted capsule");
646
- return undefined;
647
- }
648
- if (attempt.schemaVersion !== SCHEDULE_ATTEMPT_VERSION) throw scheduleError("migration-required", "scheduled attempt uses an unsupported version");
649
- const execution = validateExecutionCapsule(attempt.execution);
650
- if (lock && lock.executionId !== execution.executionId) throw scheduleError("E_SCHEDULE_INVALID", "job lock does not name the admitted execution intent");
651
- return execution;
554
+ if (attempt?.execution !== undefined || attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", CAPTURED_ATTEMPT_REMOVED);
555
+ if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", `captured job lock: ${CAPTURED_ATTEMPT_REMOVED}`);
652
556
  }
653
557
  function validateActiveExecution(js, lock) {
654
558
  if (lock?.unreadable) throw scheduleError("E_SCHEDULE_INVALID", "job lock custody is unreadable");
655
559
  if (js.attempt) return validateAttemptExecution(js.attempt, lock);
656
- if (lock && (js.lastRun?.execution || lock.executionId !== undefined)) {
657
- return validateAttemptExecution(js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}, lock);
658
- }
659
- }
660
- function receiptMatchesExecution(receipt, execution) {
661
- return !!receipt?.execution && receipt.execution.executionId === execution.executionId
662
- && executionContentIntegrity(validateExecutionCapsule(receipt.execution)).value === executionContentIntegrity(execution).value;
663
- }
664
- function admitExecution(def, io, { createIntent = true } = {}) {
665
- if (def.definitionVersion === undefined) return undefined;
666
- if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("migration-required", "scheduled definition uses an unsupported execution version");
667
- let execution;
668
- if (def.recurrencePolicy === "prepare-on-tick") {
669
- if (!createIntent) return undefined; // dry-run never prepares or publishes
670
- const prepare = io && Object.hasOwn(io, "prepare") ? io.prepare : prepareCapturedComposition;
671
- if (typeof prepare !== "function") throw scheduleError("migration-required", "prepare-on-tick needs the preparation transaction adapter before this tick can be admitted");
672
- const request = validatePreparationInput(def.preparation);
673
- const requestedDeployment = portableScope(request.deployment); // Freeze identity before preparation, including platform path aliases.
674
- const prepared = prepare(request);
675
- if (!prepared || typeof prepared !== "object" || !prepared.executionBinding || !Object.hasOwn(prepared, "responsibleHuman")) {
676
- throw scheduleError("needs-configuration", "preparation did not produce a complete executionBinding and responsibleHuman", { problems: prepared?.problems || [] });
677
- }
678
- const binding = validateExecutionBinding(prepared.executionBinding);
679
- let sameDeployment = false;
680
- try { sameDeployment = portableScope(binding.deployment) === requestedDeployment && portableScope(request.deployment) === requestedDeployment; } catch { /* absent/changed scope is not the admitted deployment */ }
681
- if (!sameDeployment) throw scheduleError("E_SCHEDULE_INVALID", "prepared executionBinding changed the requested deployment");
682
- binding.deployment = requestedDeployment; // Persist canonical authority, not a retargetable spelling.
683
- if (prepared.resolution && prepared.resolution.id !== binding.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "prepared resolution differs from executionBinding");
684
- const argv = commandArgvWithExecutionBinding(def.argv, binding);
685
- execution = buildCommandExecutionTemplate({ cwd: def.cwd, argv, inputRefs: prepared.inputRefs ?? {}, responsibleHuman: prepared.responsibleHuman });
686
- } else execution = validateExecutionTemplate(def.execution);
687
- const action = capturedDispatchAction(execution);
688
- if (action.kind === "wake") {
689
- const binding = homeExecutionBinding(execution.target.home);
690
- if (binding.deployment !== execution.deployment || binding.resolution.id !== execution.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "wake target executionBinding differs from its captured template");
691
- }
692
- const request = { deployment: execution.deployment, resolution: execution.resolution, action };
693
- (io?.admit || loadCapturedDispatch)(request);
694
- if (action.kind === "wake") throw scheduleError("migration-required", "captured wake lifecycle dispatch is not integrated yet");
695
- return createIntent ? admitExecutionTemplate(execution) : undefined;
560
+ if (lock && (js.lastRun?.execution !== undefined || lock.executionId !== undefined)) validateAttemptExecution({ execution: js.lastRun?.execution }, lock);
696
561
  }
697
562
  /** One wake. `startIfStopped` false between due minutes (a harness that
698
563
  * keeps exiting is not restarted every minute); `canStart` false when the
@@ -743,9 +608,12 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
743
608
  * marker) or its home is gone: a persistent home outliving its process
744
609
  * does not keep a slot. */
745
610
  function observePrevious(ws, id, def, js, io, now, { mutate }) {
611
+ // An invalid definition (a stored captured one included) is reported on its
612
+ // job by the tick and never observed.
613
+ if (shapeError(def)) return;
746
614
  const lock = jobLockInfo(ws, id);
747
615
  validateActiveExecution(js, lock); // No observation may erase invalid custody.
748
- if (js.attempt || shapeError(def)) return; // Unconfirmed effects need reconcile.
616
+ if (js.attempt) return; // Unconfirmed effects need reconcile.
749
617
  const lr = js.lastRun;
750
618
  const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
751
619
  const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
@@ -792,29 +660,12 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
792
660
  for (const id of order) {
793
661
  const def = defs.jobs[id];
794
662
  const js = stateOf(id);
795
- validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
796
663
  let due, cronError;
797
664
  const shape = shapeError(def);
665
+ if (!shape) validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
798
666
  if (shape) { cronError = shape; due = false; }
799
667
  else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
800
668
  if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
801
- // A captured wake is bound to the target home's exact executionBinding,
802
- // but remains gated until lifecycle exposes captured wake dispatch. Keep
803
- // this before both pending delivery and due-start branches so neither can
804
- // fall through to the legacy home path.
805
- const versionedWakeDue = def.kind === "wake" && def.definitionVersion !== undefined && !dryRun
806
- && (def.enabled || only) && ((js.pendingWake && !only) || only || (due && js.lastAttemptedMinute !== key));
807
- if (versionedWakeDue) {
808
- let error;
809
- try { admitExecution(def, io, { createIntent: false }); } catch (caught) { error = caught; }
810
- if (error) {
811
- if (only || due) js.lastAttemptedMinute = key;
812
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: "wake", launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
813
- record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
814
- continue;
815
- }
816
- throw scheduleError("migration-required", "captured wake unexpectedly passed its lifecycle integration gate");
817
- }
818
669
  if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
819
670
  const dueNow = due && js.lastAttemptedMinute !== key;
820
671
  const held = !!jobLockInfo(ws, id);
@@ -832,12 +683,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
832
683
  if (js.lastAttemptedMinute === key && !only) continue;
833
684
  if (!due) continue;
834
685
  if (dryRun) {
835
- let admission;
836
- if (!js.attempt && !jobLockInfo(ws, id) && (def.kind === "wake" || liveLocks(wsAll) < bound)) {
837
- try { admitExecution(def, io, { createIntent: false }); } catch (error) { admission = error; }
838
- }
839
- 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;
840
- record(id, reason ? (admission ? "blocked" : "skipped") : "due", reason ? { reason, ...(admission ? { errorCode: admission.code } : {}) } : {});
686
+ 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;
687
+ record(id, reason ? "skipped" : "due", reason ? { reason } : {});
841
688
  continue;
842
689
  }
843
690
  js.lastAttemptedMinute = key;
@@ -845,9 +692,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
845
692
  let invalid;
846
693
  try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
847
694
  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 } : {}) });
848
- if (!js.attempt.execution || receiptMatchesExecution(js.lastRun, js.attempt.execution)) {
849
- 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 };
850
- }
695
+ 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 };
851
696
  continue;
852
697
  }
853
698
  if (def.kind === "wake") {
@@ -864,26 +709,18 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
864
709
  }
865
710
  if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
866
711
  if (liveLocks(wsAll) >= bound) { record(id, "skipped", { reason: "host busy" }); continue; }
867
- let execution;
868
- try { execution = admitExecution(def, io); }
869
- catch (error) {
870
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: def.kind, launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
871
- record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
872
- continue;
873
- }
874
- // Persist the admitted authority before even reserving the launch slot.
875
- // A crash in the following gap leaves an inspectable unresolved attempt,
876
- // never an ownerless lock whose intended software cannot be recovered.
877
- js.attempt = { ...(execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString(), ...(execution ? { execution } : {}) };
712
+ // Persist the attempt before even reserving the launch slot. A crash in
713
+ // the following gap leaves an inspectable unresolved attempt, never an
714
+ // ownerless lock.
715
+ js.attempt = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString() };
878
716
  writeState(ws, st);
879
- if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), ...(execution ? { executionId: execution.executionId } : {}) })) {
717
+ if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString() })) {
880
718
  delete js.attempt; writeState(ws, st); record(id, "skipped", { reason: "still running" }); continue;
881
719
  }
882
720
  js.lastLaunchedAt = now.toISOString();
883
721
  writeState(ws, st);
884
722
  let run;
885
- const admitted = execution ? { ...def, cwd: execution.target.cwd, argv: execution.target.argv } : def;
886
- try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : admitted, io); }
723
+ try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
887
724
  catch (e) {
888
725
  // spawnInstance compensates its own failures, but its rollback can be
889
726
  // INCOMPLETE (a pane it could not stop, a quarantined home it kept):
@@ -893,13 +730,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
893
730
  }
894
731
  if (run.unconfirmed) {
895
732
  // Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
896
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome: "unknown" };
733
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
897
734
  record(id, "unknown", { error: run.error });
898
735
  continue;
899
736
  }
900
737
  delete js.attempt;
901
738
  const outcome = run.launched ? "launched" : "launch-failed";
902
- js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...(execution ? { execution } : {}), ...run, outcome };
739
+ js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
903
740
  if (!run.launched || !run.home) releaseJobLock(ws, id);
904
741
  record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
905
742
  }
@@ -972,27 +809,25 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
972
809
  return withHostLock(() => {
973
810
  const st = readState(ws);
974
811
  const js = st.jobs[id] || (st.jobs[id] = {});
975
- 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);
812
+ const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt, ...(js.lastRun.execution !== undefined ? { execution: js.lastRun.execution } : {}) } : undefined);
976
813
  if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
977
814
  try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
978
- catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `admitted execution capsule is invalid: ${error.message}`); }
979
- const attemptKind = attempt.execution?.action.kind || def.kind;
815
+ catch (error) { throw scheduleError("E_SCHEDULE_INVALID", error.message); }
816
+ const attemptKind = def.kind;
980
817
  let adopted, ambiguous;
981
818
  if (attemptKind === "spawn" && attempt.scheduledFor) {
982
819
  const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
983
- const homes = findHomesInScope(ws, `${def.agent}-${purpose}`);
820
+ // Only a home of THIS schedule's soul: instance names are deployment-wide
821
+ // (0.26.0), so another soul's instance can never be this run's — a name it
822
+ // holds made this run's spawn derive a suffixed name instead.
823
+ const homes = findHomesInScope(ws, `${def.agent}-${purpose}`).filter((h) => {
824
+ try { const agent = JSON.parse(readFileSync(join(h, "instance.json"), "utf8")).agent; return typeof agent !== "string" || agent === def.agent; } catch { return true; }
825
+ });
984
826
  if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
985
827
  else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
986
828
  } else if (attemptKind === "command" || attemptKind === "operation") {
987
829
  let named = [];
988
- if (attempt.execution) {
989
- // Only this admission's receipt may establish effects. Never borrow the
990
- // previous run's name or consult current-config roster discovery.
991
- if (receiptMatchesExecution(js.lastRun, attempt.execution) && typeof js.lastRun.home === "string" && isAbsolute(js.lastRun.home)) {
992
- const home = resolve(js.lastRun.home), meta = readJson(join(home, "instance.json"), null);
993
- if (meta?.instance === js.lastRun.instance && typeof meta.home === "string" && resolve(meta.home) === home) named = [{ instance: meta.instance, home }];
994
- }
995
- } else if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
830
+ if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
996
831
  if (named.length === 1) adopted = named[0];
997
832
  else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
998
833
  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}` };
@@ -1002,10 +837,10 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
1002
837
  delete js.attempt;
1003
838
  if (adopted) {
1004
839
  const seen = observeHome(adopted.home, io);
1005
- 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 } : {}) };
1006
- 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);
840
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
841
+ if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
1007
842
  } else {
1008
- 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" };
843
+ js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, 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" };
1009
844
  releaseJobLock(ws, id);
1010
845
  }
1011
846
  writeState(ws, st);
@@ -1049,7 +884,7 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
1049
884
  const js = st.jobs[id] || {};
1050
885
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
1051
886
  const lock = jobLockInfo(ws, id);
1052
- const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
887
+ const intent = js.attempt || (lock ? (js.lastRun?.execution !== undefined ? { execution: js.lastRun.execution } : {}) : undefined);
1053
888
  const { history, recentRuns } = projectHistory(js);
1054
889
  return { ...def, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
1055
890
  }
@@ -1067,8 +902,6 @@ export function addSchedule(ws, spec, io) {
1067
902
  return withScopeLock(ws, () => {
1068
903
  const defs = readDefinitions(ws);
1069
904
  if (defs.jobs[def.id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${def.id} already exists in ${ws}`);
1070
- if (defs.version === 2 && def.definitionVersion === undefined) throw scheduleError("migration-required", "version 2 schedule files require an explicit recurrence policy for new definitions");
1071
- if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
1072
905
  const now = new Date().toISOString();
1073
906
  defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
1074
907
  writeDefinitions(ws, defs);
@@ -1084,8 +917,6 @@ export function updateSchedule(ws, id, spec, io) {
1084
917
  return withHostLock(() => withScopeLock(ws, () => {
1085
918
  const defs = readDefinitions(ws);
1086
919
  if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1087
- if (defs.jobs[id].definitionVersion !== undefined && def.definitionVersion === undefined) throw scheduleError("migration-required", "a versioned schedule definition cannot be downgraded to legacy execution");
1088
- if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
1089
920
  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]);
1090
921
  const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
1091
922
  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)`);
@@ -1119,16 +950,10 @@ export function removeSchedule(ws, id, { force = false } = {}) {
1119
950
  }));
1120
951
  }
1121
952
  /** Save the wake job for a freshly spawned instance (id wake-<instance>). */
1122
- export function saveWakeForHome(ws, { instance, home, wake, executionBinding, responsibleHuman }) {
953
+ export function saveWakeForHome(ws, { instance, home, wake }) {
1123
954
  const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
1124
955
  const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
1125
- if (executionBinding !== undefined || responsibleHuman !== undefined) {
1126
- if (executionBinding === undefined || responsibleHuman === undefined) throw scheduleError("needs-configuration", "captured wake creation needs both executionBinding and responsibleHuman");
1127
- const binding = validateExecutionBinding(executionBinding), actual = homeExecutionBinding(home);
1128
- if (binding.deployment !== actual.deployment || binding.resolution.id !== actual.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "spawn result executionBinding differs from the new home");
1129
- Object.assign(spec, { definitionVersion: SCHEDULE_DEFINITION_VERSION, recurrencePolicy: "capture", responsibleHuman });
1130
- }
1131
- return addSchedule(ws, spec); // Same whole-file version/policy gate as manual add.
956
+ return addSchedule(ws, spec);
1132
957
  }
1133
958
  /** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
1134
959
  export function removeWakeForHome(ws, home) {