@awebai/oats 0.24.12 → 0.25.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 (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
package/lib/schedule.mjs CHANGED
@@ -20,12 +20,14 @@
20
20
  * succeeded. A launch whose side effects cannot be confirmed stays
21
21
  * `unknown` with its slot held until `reconcile` proves what happened. */
22
22
  import { execFileSync, spawnSync } from "node:child_process";
23
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
24
+ import { createHash } from "node:crypto";
24
25
  import { homedir } from "node:os";
25
26
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
27
28
  import { Cron } from "croner";
28
29
  import { portableScope } from "./portable-state.mjs";
30
+ import { loadLocal } from "./workspace.mjs";
29
31
  import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath, loadCapturedDispatch, prepareCapturedComposition } from "./core.mjs";
30
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
33
 
@@ -127,11 +129,32 @@ export function stateDir(ws) { return join(ws, ".agents", "schedules"); }
127
129
  function statePath(ws) { return join(stateDir(ws), "state.json"); }
128
130
  function lockDir(ws, id) { return join(stateDir(ws), "locks", id); }
129
131
 
130
- function readJson(path, fallback) {
131
- if (!existsSync(path)) return fallback;
132
- try { return JSON.parse(readFileSync(path, "utf8")); }
133
- catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file" }); }
132
+ export const SCHEDULE_HISTORY_API = 3;
133
+ export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
134
+ /** K8b — bounded, descriptor-safe JSON read: lstat → regular file only →
135
+ * O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
136
+ * within budget (an over-budget file is REFUSED as a typed error, never
137
+ * truncated into fabricated JSON). Returns { value, source }. */
138
+ function readJsonBounded(path, fallback, label) {
139
+ let st;
140
+ try { st = lstatSync(path); }
141
+ catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
142
+ if (!st.isFile()) throw scheduleError("E_SCHEDULE_INVALID", `${path} is not a regular file`, { field: "file", source: { path: label, status: "refused", bytes: 0 } });
143
+ if (st.size > SCHEDULE_FILE_BUDGET) throw scheduleError("E_SCHEDULE_STATE_OVERSIZE", `${path} is ${st.size} bytes; the read budget is ${SCHEDULE_FILE_BUDGET}`, { field: "file", source: { path: label, status: "oversize", bytes: st.size } });
144
+ let fd, text;
145
+ try {
146
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
147
+ const fst = fstatSync(fd);
148
+ if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
149
+ const buf = Buffer.alloc(fst.size); let got = 0;
150
+ while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
151
+ text = buf.toString("utf8"); st = fst;
152
+ } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read safely: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: st.size } }); }
153
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to recover */ } }
154
+ try { return { value: JSON.parse(text), source: { path: label, status: "ok", bytes: st.size } }; }
155
+ catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${path} is not valid JSON: ${e.message}`, { field: "file", source: { path: label, status: "corrupt", bytes: st.size } }); }
134
156
  }
157
+ function readJson(path, fallback) { return readJsonBounded(path, fallback, basename(path)).value; }
135
158
  function writeJson(path, value) {
136
159
  mkdirSync(dirname(path), { recursive: true });
137
160
  const tmp = `${path}.tmp-${process.pid}`;
@@ -139,27 +162,52 @@ function writeJson(path, value) {
139
162
  renameSync(tmp, path);
140
163
  }
141
164
  export function readDefinitions(ws) {
142
- const doc = readJson(definitionsPath(ws), { version: 1, jobs: {} });
165
+ const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
143
166
  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" });
144
167
  return doc;
145
168
  }
146
169
  export function writeDefinitions(ws, doc) { writeJson(definitionsPath(ws), doc); }
147
- export function readState(ws) { const st = readJson(statePath(ws), { jobs: {} }); if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
170
+ export function readState(ws) { const st = readJsonBounded(statePath(ws), { jobs: {} }, "state").value; if (!st.jobs || typeof st.jobs !== "object") st.jobs = {}; return st; }
171
+ /** Integrity facts for a scope's two files, without throwing. */
172
+ export function scheduleIntegrity(ws) {
173
+ const sources = [];
174
+ for (const [path, label, fallback] of [[definitionsPath(ws), "definitions", { version: 1, jobs: {} }], [statePath(ws), "state", { jobs: {} }]]) {
175
+ try { sources.push(readJsonBounded(path, fallback, label).source); }
176
+ catch (e) { sources.push(e.source ?? { path: label, status: "refused", bytes: 0 }); }
177
+ }
178
+ return { sources };
179
+ }
148
180
  /** K8 — bounded run history. Every time a job's lastRun reaches a settled
149
181
  * outcome (not "active"/"starting"), it is appended once to `recentRuns`
150
182
  * (newest first, max 50), keyed by scheduledFor+startedAt so re-saves of the
151
183
  * same run never duplicate it. Recording happens on save: producers keep
152
184
  * writing lastRun exactly as they do; nothing is inferred. */
153
185
  export const RECENT_RUNS_MAX = 50;
154
- function recordRecentRuns(st) {
186
+ /** K8b — a run's identity is WHEN it was scheduled and started (plus the
187
+ * attempt id when one exists), never its outcome: unknown→ended is one run
188
+ * with two transitions, not two rows. */
189
+ export function runIdOf(lr) {
190
+ if (!lr || typeof lr !== "object" || (!lr.scheduledFor && !lr.startedAt)) return null;
191
+ return createHash("sha256").update(`${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.execution?.attemptId ?? lr.attemptId ?? ""}`).digest("hex").slice(0, 24);
192
+ }
193
+ const SETTLED_OUTCOMES = new Set(["ended", "stopped", "delivered", "skipped", "blocked", "invalid", "failed", "unknown", "refused", "completed"]);
194
+ function recordRecentRuns(st, now = new Date()) {
155
195
  for (const js of Object.values(st.jobs || {})) {
156
196
  const lr = js.lastRun;
157
197
  if (!lr || typeof lr !== "object" || ["active", "starting"].includes(lr.outcome)) continue;
158
- const key = `${lr.scheduledFor ?? ""}|${lr.startedAt ?? ""}|${lr.outcome ?? ""}`;
198
+ const runId = runIdOf(lr); if (!runId) continue;
159
199
  js.recentRuns ||= [];
160
- if (js.recentRuns[0]?.key === key) { js.recentRuns[0] = { key, ...lr }; continue; } // same run, later fields
161
- if (js.recentRuns.some((r) => r.key === key)) continue;
162
- js.recentRuns.unshift({ key, ...lr });
200
+ const settled = SETTLED_OUTCOMES.has(lr.outcome) && lr.pending !== true;
201
+ const existing = js.recentRuns.find((r) => r.runId === runId);
202
+ if (existing) {
203
+ // same run, later facts: update in place, keep the outcome sequence
204
+ const prev = existing.outcome;
205
+ Object.assign(existing, lr, { runId, settled, recordedAt: now.toISOString() });
206
+ existing.transitions = [...(existing.transitions || [prev]), ...(prev !== lr.outcome ? [lr.outcome] : [])];
207
+ continue;
208
+ }
209
+ // legacy rows (pre-K8b, keyed by outcome) are left as they are: never merged, reported as legacy
210
+ js.recentRuns.unshift({ runId, ...lr, settled, recordedAt: now.toISOString(), transitions: [lr.outcome] });
163
211
  if (js.recentRuns.length > RECENT_RUNS_MAX) js.recentRuns.length = RECENT_RUNS_MAX;
164
212
  }
165
213
  }
@@ -427,13 +475,97 @@ export function findHomesInScope(ws, instance) {
427
475
  function scheduleBlock(def, minute) {
428
476
  return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.id}" for ${minute.toISOString()} (cron "${def.cron}" in ${def.tz}). It is disposable: finish the task above, bring your memory files up to date, and end with \`oats retire --self\`.\n`;
429
477
  }
430
- /** Launch one spawn job. `io.spawn(root, agent, opts)` overrides spawnInstance for tests. */
478
+ /** The environment a scheduler-launched oats child runs with: the caller's
479
+ * frozen instance identity and capability snapshot removed (a tick or run-now
480
+ * may be invoked from inside an unrelated instance; dispatch belongs to the
481
+ * job's cwd and explicit selectors, never to that caller), host configuration
482
+ * such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
483
+ function childEnv() {
484
+ const env = { ...process.env };
485
+ for (const key of [...RESERVED_LAUNCH_ENV,
486
+ "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
487
+ "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
488
+ "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
489
+ "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
490
+ ]) delete env[key];
491
+ return env;
492
+ }
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;
509
+ }
510
+ /** A workspace spawn materializes EXACTLY like `oats spawn` — prepareInstance →
511
+ * ensureWorkspaceSoul → spawnInstanceAsync (resolve over the remotes, copy every
512
+ * module whole into the home, compose AGENTS.md from the modules' injects).
513
+ * That chain is asynchronous (it reads Git remotes); the tick chain is
514
+ * synchronous by design (one short-lived process under the host lock, no
515
+ * daemon). So the tick delegates to the kernel's own CLI as a child process,
516
+ * like a command job: `oats spawn <soul> --json …` in the deployment, and
517
+ * reads back the one JSON envelope. The child holds no host lock (a spawn
518
+ * takes only the scope lock, briefly, for a wake it saves — and the schedule
519
+ * passes no wake flags: the wake is saved here, from the run record).
520
+ * `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
521
+ function spawnViaCli(ws, root, agent, opts, context, io) {
522
+ if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
523
+ const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
524
+ if (opts.runtime) argv.push("--runtime", opts.runtime);
525
+ if (opts.model) argv.push("--model", opts.model);
526
+ if (opts.backend) argv.push("--backend", opts.backend);
527
+ if (opts.repo) argv.push("--repo", opts.repo);
528
+ if (opts.yolo === true) argv.push("--yolo"); else if (opts.yolo === false) argv.push("--no-yolo");
529
+ if (opts.launch === false) argv.push("--no-launch");
530
+ // The task is multi-line text of arbitrary size: it travels as a private file, never in argv.
531
+ mkdirSync(join(stateDir(ws), "tasks"), { recursive: true });
532
+ const taskFile = join(stateDir(ws), "tasks", `${opts.purpose}-${process.pid}.md`);
533
+ writeFileSync(taskFile, opts.task, { mode: 0o600 });
534
+ argv.push("--task-file", taskFile);
535
+ let r;
536
+ try { r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
537
+ finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
538
+ const timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
539
+ const envelope = parseEnvelopeText(r.stdout);
540
+ // The child may have created (and launched) a home before dying: unconfirmed, never a confirmed failure.
541
+ // The thrown message reports RETAINED effects so the tick keeps the attempt and its slot for reconcile.
542
+ if (timedOut) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn timed out; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
543
+ if (!envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_UNCONFIRMED", `workspace spawn answered no valid envelope (exit ${r.status ?? r.signal}); its effects could not be confirmed — run oats schedule reconcile${r.stderr ? `: ${String(r.stderr).trim().split("\n").pop()}` : ""}`);
544
+ if (!envelope.ok) {
545
+ const e = envelope.error || {};
546
+ // E_SPAWN_INCOMPLETE: the kernel created a home it could not finish or roll back — retained effects.
547
+ const retained = e.code === "E_SPAWN_INCOMPLETE" || e.details?.home;
548
+ throw scheduleError(e.code || "E_SPAWN_FAILED", retained ? `${e.message} (home retained; effects unconfirmed)` : String(e.message || "spawn failed"), { details: e.details });
549
+ }
550
+ const res = envelope.result || {};
551
+ return { instance: res.instance, home: res.home, launched: res.launched, materialized: "workspace" };
552
+ }
553
+ /** 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. */
431
558
  function launchSpawn(ws, def, minute, io) {
432
559
  const { root, agent } = resolveScheduledAgent(ws, def);
433
560
  const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
434
- 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: true };
435
- const r = (io?.spawn || spawnInstance)(root, agent, opts);
436
- const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn" };
561
+ 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
+ let r;
563
+ 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
+ }
568
+ const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn", ...(r.materialized ? { materialized: r.materialized } : {}) };
437
569
  if (def.wake && r.home) {
438
570
  try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
439
571
  catch (e) { run.wakeScheduleError = { code: e.code || "E_SCHEDULE_INVALID", message: e.message }; }
@@ -467,18 +599,7 @@ function launchCommand(ws, def, io) {
467
599
  let envelope, timedOut = false, raw = "";
468
600
  if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
469
601
  else {
470
- const env = { ...process.env };
471
- // A schedule may be ticked/run-now from inside an unrelated instance.
472
- // Dispatch belongs to the job's cwd/explicit selectors, never that
473
- // caller's frozen capability snapshot (including legacy OATS_HOME).
474
- // Keep host configuration such as OATS_HOME_DIR and credentials intact.
475
- for (const key of [...RESERVED_LAUNCH_ENV,
476
- "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
477
- "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
478
- "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
479
- "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
480
- ]) delete env[key];
481
- const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
602
+ const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
482
603
  timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
483
604
  raw = String(r.stdout || "");
484
605
  envelope = parseEnvelopeText(raw);
@@ -502,7 +623,10 @@ function launchCommand(ws, def, io) {
502
623
  const named = instance || (partial && typeof partial.instance === "string" ? partial.instance : undefined);
503
624
  return { kind: "command", launched: false, unconfirmed: true, ...(named ? { instance: named } : {}), error: `command outcome unconfirmed: ${envelope.error?.message}`, errorCode: envelope.error?.code };
504
625
  }
505
- return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
626
+ // Provenance the recorder has at write time: the answering server (remote
627
+ // spawn) and the launched home's incarnation. Never a transcript.
628
+ const server = typeof res.server === "string" ? res.server : undefined;
629
+ 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 }) };
506
630
  }
507
631
 
508
632
  /** Verify an immutable capsule and its current exact-artifact authorization
@@ -596,12 +720,16 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
596
720
  if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
597
721
  try {
598
722
  const r = (io?.start || startInstanceSession)(def.home);
599
- return { kind: "wake", action: "started", instance: r.instance, target: r.target, reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
723
+ return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
600
724
  } catch (e) {
601
725
  return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
602
726
  }
603
727
  }
604
- try { (io?.input || inputInstanceSession)(def.home, def.message); return { kind: "wake", action: "delivered", pending: false }; }
728
+ try {
729
+ const r = (io?.input || inputInstanceSession)(def.home, def.message);
730
+ // Delivery custody: name the instance ONLY when the input result names it; the home's current incarnation at record time.
731
+ return { kind: "wake", action: "delivered", pending: false, ...(typeof r?.instance === "string" ? { instance: r.instance } : {}), home: def.home, incarnation: incarnationOfHome(def.home) };
732
+ }
605
733
  catch (e) { return { kind: "wake", action: "skipped", reason: `input refused: ${e.message}`, pending: true }; }
606
734
  }
607
735
 
@@ -887,20 +1015,52 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
887
1015
 
888
1016
  // -------------------------------------------------------- definitions CRUD
889
1017
 
1018
+ export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
1019
+ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); return typeof m?.createdAt === "string" ? m.createdAt : null; } catch { return null; } }
1020
+ /** K8b — session PROVENANCE of a run (facts the recorder had), never a
1021
+ * transcript: there is no reader behind this block. */
1022
+ function sessionProvenanceOf(r) {
1023
+ if (!r || typeof r !== "object") return null;
1024
+ const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
1025
+ return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
1026
+ }
1027
+ /** Read-time projection of one job's stored history: bounded to
1028
+ * RECENT_RUNS_MAX, legacy rows marked, corruption isolated to the job. */
1029
+ function projectHistory(js) {
1030
+ const stored = js?.recentRuns;
1031
+ if (stored === undefined || stored === null) return { history: { status: "ok", stored: 0, truncated: false }, recentRuns: [] };
1032
+ if (!Array.isArray(stored)) return { history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] };
1033
+ const rows = [];
1034
+ for (const r of stored.slice(0, RECENT_RUNS_MAX)) {
1035
+ if (!r || typeof r !== "object") { rows.push({ runId: null, legacy: true, corrupt: true }); continue; }
1036
+ const { key, ...rest } = r;
1037
+ const legacy = typeof r.runId !== "string";
1038
+ rows.push({ ...rest, runId: legacy ? null : r.runId, legacy, ...(legacy ? { settled: null, transitions: null } : {}), session: sessionProvenanceOf(rest) });
1039
+ }
1040
+ return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
1041
+ }
890
1042
  export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
1043
+ if (typeof id !== "string" || !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(id)})`);
891
1044
  defs ||= readDefinitions(ws); st ||= readState(ws);
892
1045
  const def = defs.jobs[id];
893
1046
  if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1047
+ // Subject truth: the definition's own id must be the key it is stored under.
1048
+ if (def.id !== undefined && def.id !== id) throw scheduleError("E_SCHEDULE_IDENTITY", `schedule stored under ${JSON.stringify(id)} declares id ${JSON.stringify(def.id)}`, { key: id, declared: def.id });
894
1049
  const js = st.jobs[id] || {};
895
1050
  let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
896
1051
  const lock = jobLockInfo(ws, id);
897
1052
  const intent = js.attempt || (lock ? (js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}) : undefined);
898
- const recentRuns = (js.recentRuns || []).map(({ key, ...r }) => ({ ...r, ...(r.instance && r.home ? { transcript: { instance: r.instance, home: r.home, kind: "session" } } : {}) }));
899
- return { ...def, scheduleApi: 2, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun || null, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
1053
+ const { history, recentRuns } = projectHistory(js);
1054
+ 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 } : {}) };
900
1055
  }
901
1056
  export function listSchedules(ws, io, { now = new Date() } = {}) {
902
1057
  const defs = readDefinitions(ws), st = readState(ws);
903
- return { schedules: Object.keys(defs.jobs).sort().map((id) => describe(ws, id, io, { defs, st, now })), scheduler: schedulerStatus(ws, io) };
1058
+ // One job's bad identity or history does not fail the others: it is reported as its own row.
1059
+ const schedules = Object.keys(defs.jobs).sort().map((id) => {
1060
+ try { return describe(ws, id, io, { defs, st, now }); }
1061
+ catch (e) { return { id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, unreadable: { code: e.code || "E_SCHEDULE_INVALID", message: e.message }, history: { status: "corrupt", stored: null, truncated: false }, recentRuns: [] }; }
1062
+ });
1063
+ return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
904
1064
  }
905
1065
  export function addSchedule(ws, spec, io) {
906
1066
  const def = validateDefinition(ws, spec);