@awebai/oats 0.27.2 → 0.29.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 (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/lib/schedule.mjs CHANGED
@@ -29,6 +29,9 @@ import { fileURLToPath } from "node:url";
29
29
  import { Cron } from "croner";
30
30
  import { loadLocal } from "./workspace.mjs";
31
31
  import { noteRuntimeName } from "./deprecation.mjs";
32
+ import { tickTriggers } from "./triggers.mjs";
33
+ import { resolveMemberClone } from "./instance-resolution.mjs";
34
+ import { AUTOMATION_ID_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
32
35
  import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
33
36
 
34
37
  export const SCHEDULE_FILE = "oats-schedules.json";
@@ -65,6 +68,7 @@ const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/
65
68
  export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
66
69
  export function shapeError(def) {
67
70
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
71
+ if (def.kind === "trigger") return "a trigger is managed by oats trigger, not oats schedule";
68
72
  if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
69
73
  if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
70
74
  if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
@@ -246,7 +250,7 @@ export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join
246
250
  /** Registry read-modify-write is serialized on its own short lock. */
247
251
  function withRegistryLock(fn) { return withDirLock(join(hostScheduleDir(), "registry.lock"), "the host schedule registry", fn, { retryMs: 3000 }); }
248
252
  /** Definitions and state are read-modify-write; CRUD serializes here briefly. */
249
- function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
253
+ export function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
250
254
 
251
255
  export function acquireJobLock(ws, id, owner) {
252
256
  const dir = lockDir(ws, id);
@@ -288,6 +292,7 @@ function validateMessage(message, field) {
288
292
  /** Validate and normalize one definition against its scope. */
289
293
  export function validateDefinition(ws, def, { checkAgent = true } = {}) {
290
294
  if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
295
+ if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_INVALID", "kind: a trigger is added with `oats trigger add`, not `oats schedule add`", { field: "kind" });
291
296
  const id = def.id;
292
297
  if (typeof id !== "string" || !ID_RE.test(id)) throw scheduleError("E_SCHEDULE_INVALID", "id: lowercase letters, digits and dashes, 1 to 40 characters", { field: "id" });
293
298
  const enabled = def.enabled === undefined ? true : def.enabled;
@@ -316,6 +321,7 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
316
321
  noteRuntimeName(`schedule ${id}: runtime (written as harness)`);
317
322
  def = { ...def, harness: def.runtime };
318
323
  }
324
+ if (def.launchConfig !== undefined) { if (typeof def.launchConfig !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(def.launchConfig)) throw scheduleError("E_SCHEDULE_INVALID", "launchConfig: the name of a launch configuration on the running host (oats-local.yaml launch-configs)", { field: "launchConfig" }); out.launchConfig = def.launchConfig; }
319
325
  if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
320
326
  if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
321
327
  if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
@@ -351,11 +357,65 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
351
357
  return out;
352
358
  }
353
359
 
360
+ /** The filesystem-safe key a workspace schedule is stored under in the local run state (job locks
361
+ * are directories named by it): `<member>~<id>`. Repository names never carry `~`; local ids
362
+ * (SCHEDULE_ID_RE) never do either. */
363
+ export const scheduleStateKey = (qid) => String(qid).replace("/", "~");
364
+ /** The jobs a tick, run or reconcile works on: the deployment's local jobs (stored under their
365
+ * bare id) and, from the automations context (its `schedules`), the workspace schedules THIS host runs (stored
366
+ * under scheduleStateKey(<member>/<id>), a filesystem-safe key: job locks are directories). */
367
+ export function effectiveDefinitions(ws, ctx) {
368
+ const defs = readDefinitions(ws);
369
+ for (const a of ctx?.schedules || []) {
370
+ if (!a.placement.runsHere) continue;
371
+ const key = scheduleStateKey(a.id);
372
+ defs.jobs[key] = { ...a.definition, id: key, enabled: true, purpose: a.definition.purpose ?? a.name, workspace: { qid: a.id, name: a.name, member: a.member, origin: a.origin, runsOn: a.runsOn, owner: a.owner } };
373
+ }
374
+ return defs;
375
+ }
376
+ /** The stored key of a schedule argument (`local/<id>`, a bare local id, or `<member>/<id>`). */
377
+ export function scheduleKeyOf(id) { const { scope, name } = splitId(id); return scope === "local" ? name : scheduleStateKey(`${scope}/${name}`); }
378
+ /** A workspace schedule file (0.29.0; the shared header is lib/automations.mjs's):
379
+ * `oats-schedules/<id>.yaml` or `*.oats-schedule.yaml`, `kind: oats-schedule`. Its body is the
380
+ * run (`run: spawn | command`, cron, tz, …); a wake or an operation targets an instance home on
381
+ * one machine and stays local. `expand` checks it against the deployment `dep`. */
382
+ export function scheduleKind({ dep = null } = {}) {
383
+ return {
384
+ kind: "schedule", folder: "oats-schedules", suffix: "oats-schedule", fileKind: "oats-schedule",
385
+ bodyKeys: ["run", "cron", "tz", "agent", "task", "purpose", "launchConfig", "harness", "model", "yolo", "backend", "wake", "argv", "cwd"],
386
+ parseBody: (body) => {
387
+ const bad = (message) => Object.assign(new Error(message), { field: "run" });
388
+ const { run = "spawn", ...rest } = body;
389
+ if (run === "wake" || run === "operation") throw bad(`run: ${run} targets an instance home on one machine; declare it locally (oats schedule add), not in Git`);
390
+ if (run !== "spawn" && run !== "command") throw bad("run: spawn or command");
391
+ return { definition: { ...rest, kind: run } };
392
+ },
393
+ expand: (a) => validateWorkspaceSchedule(dep, a),
394
+ };
395
+ }
396
+ /** A workspace schedule, as a validated kernel definition for the snapshot: the file's run facts
397
+ * checked like `oats schedule add` (the soul is resolved when it runs, through discovery); a
398
+ * command's cwd is relative to the deployment. */
399
+ export function validateWorkspaceSchedule(dep, a) {
400
+ const d = a.source?.definition || {};
401
+ const spec = { ...d, id: a.name, enabled: true };
402
+ if (spec.kind === "command") {
403
+ if (d.cwd !== undefined && (typeof d.cwd !== "string" || isAbsolute(d.cwd))) throw scheduleError("E_SCHEDULE_INVALID", "cwd: a directory relative to the deployment (a workspace schedule never names a machine path)", { field: "cwd" });
404
+ spec.cwd = resolve(dep, d.cwd ?? ".");
405
+ }
406
+ const out = validateDefinition(dep, spec, { checkAgent: false });
407
+ delete out.enabled;
408
+ return out;
409
+ }
410
+
354
411
  /** The soul a spawn job launches, under the deployment's one agents root
355
412
  * (a definition's agentsRoot, when given, must be that root). `repo` is the
356
413
  * work repository and never selects a soul. */
357
414
  export function resolveScheduledAgent(ws, def) {
358
415
  const [root] = scopeRoots(ws);
416
+ // A workspace schedule (0.29.0) names its soul as `oats spawn <soul>` does: discovery resolves
417
+ // it (a member or package soul), whether or not this deployment has spawned it before.
418
+ if (def.workspace) return { root, agent: { name: def.agent }, discover: true };
359
419
  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" });
360
420
  const agent = findAgent(root, def.agent);
361
421
  if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
@@ -424,20 +484,20 @@ export function findHomesInScope(ws, instance) {
424
484
  // --------------------------------------------------------------- runner
425
485
 
426
486
  function scheduleBlock(def, minute) {
427
- 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`;
487
+ return `\n\n## Scheduled run\n\nThis instance was launched by OATS schedule "${def.workspace?.qid ?? 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`;
428
488
  }
429
489
  /** The environment a scheduler-launched oats child runs with: the caller's
430
490
  * frozen instance identity and capability snapshot removed (a tick or run-now
431
491
  * may be invoked from inside an unrelated instance; dispatch belongs to the
432
492
  * job's cwd and explicit selectors, never to that caller), host configuration
433
493
  * such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
434
- function childEnv() {
494
+ export function childEnv() {
435
495
  const env = { ...process.env };
436
496
  for (const key of [...RESERVED_LAUNCH_ENV,
437
497
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
438
498
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
439
499
  "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
440
- "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
500
+ "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE", "OATS_TRIGGER_EVENT_FILE",
441
501
  ]) delete env[key];
442
502
  return env;
443
503
  }
@@ -461,7 +521,8 @@ export function workspaceSpawnContext(ws) {
461
521
  * `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
462
522
  function spawnViaCli(ws, root, agent, opts, context, io) {
463
523
  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}`);
464
- const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
524
+ const argv = ["spawn", agent.name, "--dir", context.dir, ...(opts.discover ? [] : ["--agents-root", root]), "--purpose", opts.purpose, "--json"];
525
+ if (opts.launchConfig) argv.push("--launch-config", opts.launchConfig);
465
526
  if (opts.harness) argv.push("--harness", opts.harness);
466
527
  if (opts.model) argv.push("--model", opts.model);
467
528
  if (opts.backend) argv.push("--backend", opts.backend);
@@ -496,9 +557,9 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
496
557
  * deployment (workspace materialization: `.oats/modules`, module skills and
497
558
  * injects). */
498
559
  function launchSpawn(ws, def, minute, io) {
499
- const { root, agent } = resolveScheduledAgent(ws, def);
560
+ const { root, agent, discover } = resolveScheduledAgent(ws, def);
500
561
  const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
501
- const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
562
+ const opts = { discover, task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, launchConfig: def.launchConfig, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
502
563
  let r;
503
564
  if (io?.spawn) r = io.spawn(root, agent, opts);
504
565
  else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
@@ -656,13 +717,13 @@ function observePrevious(ws, id, def, js, io, now, { mutate }) {
656
717
  * reads only: no lock, state or definition is touched. Due jobs are
657
718
  * visited least-recently-launched first so one job cannot monopolize the
658
719
  * slot. An invalid definition is recorded on that job and the rest continue. */
659
- export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun = false, only, observeOnly = false, candidates } = {}) {
720
+ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun = false, only, observeOnly = false, candidates, ctx = null } = {}) {
660
721
  const minute = minuteStart(now);
661
722
  const key = minuteKey(minute);
662
723
  const registry = reg || readRegistry();
663
724
  const bound = registry.maxConcurrent || 1;
664
725
  const wsAll = wsList || [ws];
665
- const defs = readDefinitions(ws);
726
+ const defs = effectiveDefinitions(ws, ctx);
666
727
  const st = readState(ws);
667
728
  const considered = [];
668
729
  const stateOf = (id) => (st.jobs[id] ||= {});
@@ -671,6 +732,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
671
732
  // are gone), then decide launches: a job ordered earlier must not see a
672
733
  // slot still held by a home that has already ended.
673
734
  // `candidates` (the host tick) means the host already observed every scope.
735
+ // Triggers (kind "trigger") live in the same file and are evaluated by tickTriggers, not here.
736
+ for (const [id, def] of Object.entries(defs.jobs)) if (def && def.kind === "trigger") delete defs.jobs[id];
674
737
  if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
675
738
  if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
676
739
  // Least recently LAUNCHED first: only an actual harness launch counts, a
@@ -763,6 +826,15 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
763
826
  return considered;
764
827
  }
765
828
 
829
+ /** One scope's automations context (lib/automations.mjs automationContext) with its oats-local.yaml;
830
+ * an unreadable oats-local.yaml means no workspace automations (the local jobs still run). */
831
+ export function scopeAutomations(ws, { io, now = new Date(), refresh = false, cache = new Map() } = {}) {
832
+ let local = null;
833
+ try { local = loadLocal(ws).local; } catch { local = null; }
834
+ const clonePathOf = (repoKey) => resolveMemberClone({ deployment: ws, soulEntry: { repoKey }, local });
835
+ const ctx = automationContext(ws, { local, io: { ...(io || {}), childEnv }, now, refresh, cache, clonePathOf });
836
+ return Object.assign(ctx, { ws, local });
837
+ }
766
838
  /** The host tick: every registered scope under the host lock. */
767
839
  export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
768
840
  const reg = readRegistry();
@@ -774,18 +846,29 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
774
846
  // every-minute job in the first registered scope cannot monopolize the
775
847
  // bound. An unreadable scope is recorded and the rest continue.
776
848
  const jobs = [];
849
+ // Each scope's workspace automations, placed on this host once per tick (one gh identity
850
+ // lookup per GitHub host), the snapshot refreshed first when it is ten minutes old.
851
+ const cache = new Map(), contexts = new Map();
852
+ for (const ws of wsList) contexts.set(ws, scopeAutomations(ws, { io, now, refresh: !dryRun, cache }));
853
+ for (const ctx of contexts.values()) if (ctx.refresh && !ctx.refresh.ok) considered.push({ workspace: ctx.ws, action: "error", error: `automations refresh: ${ctx.refresh.error}` });
777
854
  for (const ws of wsList) {
855
+ const ctx = contexts.get(ws);
778
856
  try {
779
- considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true }));
780
- const defs = readDefinitions(ws), st = readState(ws);
781
- for (const id of Object.keys(defs.jobs)) jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
857
+ considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true, ctx }));
858
+ const defs = effectiveDefinitions(ws, ctx), st = readState(ws);
859
+ for (const [id, def] of Object.entries(defs.jobs)) if (def?.kind !== "trigger") jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
782
860
  } catch (e) { considered.push({ workspace: ws, action: "error", error: e.message }); }
783
861
  }
784
862
  jobs.sort((a, b) => a.key.localeCompare(b.key) || a.ws.localeCompare(b.ws) || a.id.localeCompare(b.id));
785
863
  for (const j of jobs) {
786
- try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id] })); }
864
+ try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id], ctx: contexts.get(j.ws) })); }
787
865
  catch (e) { considered.push({ workspace: j.ws, id: j.id, action: "error", error: e.message }); }
788
866
  }
867
+ // Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
868
+ for (const ws of wsList) {
869
+ try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws) })); }
870
+ catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
871
+ }
789
872
  if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
790
873
  return { tickedAt: now.toISOString(), minute: minuteKey(minuteStart(now)), considered, scheduler: schedulerStatus(undefined, io) };
791
874
  };
@@ -794,10 +877,11 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
794
877
 
795
878
  /** Run one job now, ignoring its cron, under the same lock and bound; the
796
879
  * previous run is observed under the host lock before any lock is judged. */
797
- export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
798
- const defs = readDefinitions(ws);
880
+ export function runNow(ws, qid, { now = new Date(), io, force = false, ctx = null } = {}) {
881
+ const id = scheduleKeyOf(qid);
882
+ const defs = effectiveDefinitions(ws, ctx);
799
883
  const def = defs.jobs[id];
800
- if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
884
+ if (!def || def.kind === "trigger") throw notHere(ws, qid, ctx);
801
885
  if (!def.enabled && !force) throw scheduleError("E_SCHEDULE_DISABLED", `schedule ${id} is disabled; enable it or pass --force`);
802
886
  const reg = readRegistry();
803
887
  const wsList = reg.workspaces.includes(resolve(ws)) ? reg.workspaces : [...reg.workspaces, resolve(ws)];
@@ -809,10 +893,10 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
809
893
  if (js.attempt) throw scheduleError("E_SCHEDULE_UNRESOLVED", `schedule ${id} has a launch attempt without a recorded result; run oats schedule reconcile ${id} first`);
810
894
  if (def.kind !== "wake" && jobLockInfo(ws, id)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is still running`);
811
895
  if (def.kind !== "wake" && liveLocks(wsList) >= reg.maxConcurrent) throw scheduleError("E_SCHEDULER_BUSY", `host concurrency ${reg.maxConcurrent} reached`);
812
- const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id });
896
+ const considered = tickWorkspace(ws, { now, io, reg, wsList, only: id, ctx });
813
897
  const after = readState(ws);
814
898
  if (after.jobs[id]) { after.jobs[id].lastAttemptedMinute = minuteKey(minuteStart(now)); writeState(ws, after); }
815
- return { schedule: describe(ws, id, io), run: after.jobs[id]?.lastRun || null, considered };
899
+ return { schedule: describe(ws, qid, io, { ctx }), run: after.jobs[id]?.lastRun || null, considered };
816
900
  });
817
901
  }
818
902
 
@@ -821,15 +905,17 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
821
905
  * identity), a command attempt only the instance its answer NAMED. Nothing
822
906
  * is inferred from file times. An unnamed command effect stays unknown
823
907
  * until the operator has checked by hand and passes `clear`. */
824
- export function reconcile(ws, id, { io, now = new Date(), clear = false } = {}) {
825
- const defs = readDefinitions(ws);
908
+ export function reconcile(ws, qid, { io, now = new Date(), clear = false, ctx = null } = {}) {
909
+ const id = scheduleKeyOf(qid);
910
+ const defs = effectiveDefinitions(ws, ctx);
826
911
  const def = defs.jobs[id];
827
- if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
912
+ if (!def || def.kind === "trigger") throw notHere(ws, qid, ctx);
913
+ const describeHere = () => describe(ws, qid, io, { ctx });
828
914
  return withHostLock(() => {
829
915
  const st = readState(ws);
830
916
  const js = st.jobs[id] || (st.jobs[id] = {});
831
917
  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);
832
- if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
918
+ if (!attempt) return { schedule: describeHere(), reconciled: "nothing to reconcile" };
833
919
  try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
834
920
  catch (error) { throw scheduleError("E_SCHEDULE_INVALID", error.message); }
835
921
  const attemptKind = def.kind;
@@ -849,9 +935,9 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
849
935
  if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
850
936
  if (named.length === 1) adopted = named[0];
851
937
  else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
852
- else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven from a matching admitted receipt; check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
938
+ else if (!clear) return { schedule: describeHere(), 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}` };
853
939
  }
854
- if (ambiguous && !clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
940
+ if (ambiguous && !clear) return { schedule: describeHere(), reconciled: "unknown", remedy: `${ambiguous}; retire or adopt by hand, then run oats schedule reconcile ${id} --clear or oats schedule remove --force ${id}` };
855
941
  if (ambiguous) adopted = undefined;
856
942
  delete js.attempt;
857
943
  if (adopted) {
@@ -863,13 +949,61 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
863
949
  releaseJobLock(ws, id);
864
950
  }
865
951
  writeState(ws, st);
866
- return { schedule: describe(ws, id, io), reconciled: adopted ? "adopted" : "cleared" };
952
+ return { schedule: describeHere(), reconciled: adopted ? "adopted" : "cleared" };
867
953
  });
868
954
  }
869
955
 
870
956
  // -------------------------------------------------------- definitions CRUD
871
957
 
872
958
  export const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
959
+ /** A schedule id this host cannot run: unknown, or a workspace schedule placed elsewhere. */
960
+ /** `oats schedule test <id>`: what running it on this host would do, without running it — its
961
+ * placement, whether its soul resolves (the kernel's own `oats spawn <soul> --preview`, which
962
+ * writes nothing) and when it is next due. Spawns nothing, records nothing, takes no lock. */
963
+ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {}) {
964
+ const row = describe(ws, qid, io, { now, ctx });
965
+ const { scope, name } = splitId(qid);
966
+ const entry = scope === "local" ? null : (ctx?.schedules || []).find((x) => x.id === `${scope}/${name}`);
967
+ const def = entry ? { ...entry.definition, workspace: { qid: entry.id } } : readDefinitions(ws).jobs[name];
968
+ const problems = [];
969
+ if (row.invalid) problems.push(`${row.qualifiedId} is invalid: ${row.invalid.message}`);
970
+ if (!row.runsHere) problems.push(`${row.qualifiedId} does not run on this host (${row.reason ?? "disabled here"}${row.reasonDetail ? `: ${row.reasonDetail}` : ""})`);
971
+ if (!row.enabled) problems.push(`${row.qualifiedId} is disabled (oats schedule enable ${row.qualifiedId})`);
972
+ // The next cron match, whether or not this host runs it (placement says that).
973
+ let nextDue = null; try { nextDue = def?.cron ? nextRunAfter(def, now) : null; } catch (e) { problems.push(`cron: ${e.message}`); }
974
+ let soul = null;
975
+ if (def?.kind === "spawn") {
976
+ soul = { name: def.agent, origin: row.soul?.origin ?? null, resolves: false, error: null };
977
+ try {
978
+ const { root, discover } = resolveScheduledAgent(ws, def);
979
+ const context = workspaceSpawnContext(ws);
980
+ if (context.error) throw scheduleError(context.error.code, context.error.message);
981
+ const argv = ["spawn", def.agent, "--dir", context.dir, ...(discover ? [] : ["--agents-root", root]), "--preview", "--json"];
982
+ if (def.launchConfig) argv.push("--launch-config", def.launchConfig);
983
+ if (def.harness) argv.push("--harness", def.harness);
984
+ if (def.model) argv.push("--model", def.model);
985
+ const 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() });
986
+ const envelope = parseEnvelopeText(r.stdout);
987
+ if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
988
+ if (!envelope.ok) throw scheduleError(envelope.error?.code || "E_SPAWN_FAILED", String(envelope.error?.message || "spawn preview failed"));
989
+ soul.resolves = true;
990
+ } catch (e) {
991
+ soul.error = { code: e.code || "E_SPAWN_FAILED", message: e.message };
992
+ problems.push(`soul ${def.agent} does not resolve: ${e.message}`);
993
+ }
994
+ }
995
+ return {
996
+ id: row.id, qualifiedId: row.qualifiedId, kind: row.kind,
997
+ placement: { runsHere: row.runsHere, reason: row.reason, ...(row.reasonDetail ? { reasonDetail: row.reasonDetail } : {}), enabledHere: row.enabledHere, runsOn: row.runsOn, owner: row.owner, host: ctx?.host?.name ?? null },
998
+ soul, nextDue, spawned: false, problems, ok: problems.length === 0,
999
+ };
1000
+ }
1001
+ function notHere(ws, qid, ctx) {
1002
+ const { scope } = splitId(qid);
1003
+ const a = scope !== "local" ? (ctx?.schedules || []).find((x) => x.id === qid) : null;
1004
+ if (a) return automationError("E_AUTOMATION_NOT_HERE", `${qid} does not run on this host (${a.placement.reason ?? (a.invalid ? "invalid" : "disabled here")}${a.placement.detail ? `: ${a.placement.detail}` : a.invalid ? `: ${a.invalid.message}` : ""})`, { id: qid, reason: a.placement.reason, runsOn: a.runsOn, owner: a.owner });
1005
+ return scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(qid)} in ${ws}${scope !== "local" && !ctx?.snapshot ? " (no automations snapshot yet: run oats sync)" : ""}`);
1006
+ }
873
1007
  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; } }
874
1008
  /** K8b — session PROVENANCE of a run (facts the recorder had), never a
875
1009
  * transcript: there is no reader behind this block. */
@@ -893,11 +1027,22 @@ function projectHistory(js) {
893
1027
  }
894
1028
  return { history: { status: "ok", stored: stored.length, truncated: stored.length > RECENT_RUNS_MAX }, recentRuns: rows };
895
1029
  }
896
- export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
897
- 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)})`);
898
- defs ||= readDefinitions(ws); st ||= readState(ws);
1030
+ /** One schedule's row: the stored job's facts (a workspace schedule this host runs is a job
1031
+ * under its state key) plus the automations row (docs/desktop-cli-api.md "Automations"). A
1032
+ * workspace schedule another host runs has no local facts: its definition and placement only. */
1033
+ export function describe(ws, qid, io, { defs, st, now = new Date(), ctx = null } = {}) {
1034
+ if (typeof qid !== "string" || !qid) throw scheduleError("E_BAD_ARGS", `oats schedule show <id> (local/<id> or <member>/<id>)`);
1035
+ const { scope, name } = splitId(qid);
1036
+ if (scope !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope) || !AUTOMATION_ID_RE.test(name))) throw scheduleError("E_BAD_ARGS", `a workspace schedule id is <member>/<id> (got ${JSON.stringify(qid)})`);
1037
+ const entry = scope === "local" ? null : (ctx?.schedules || []).find((x) => x.id === `${scope}/${name}`);
1038
+ if (scope !== "local" && !entry) throw notHere(ws, `${scope}/${name}`, ctx);
1039
+ if (entry && !entry.placement.runsHere) return scheduleRow(ws, entry, { ...(entry.definition || {}), scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, nextRun: null, lastRun: null, running: false }, ctx);
1040
+ const id = scope === "local" ? name : scheduleStateKey(entry.id);
1041
+ if (scope === "local" && !SCHEDULE_ID_RE.test(id)) throw scheduleError("E_BAD_ARGS", `schedule id must match ${SCHEDULE_ID_RE} (got ${JSON.stringify(qid)})`);
1042
+ defs ||= effectiveDefinitions(ws, ctx); st ||= readState(ws);
899
1043
  const def = defs.jobs[id];
900
- if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1044
+ if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(qid)} in ${ws}`);
1045
+ if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `${qid} is a trigger, not a schedule — use oats trigger show ${qid}`);
901
1046
  // Subject truth: the definition's own id must be the key it is stored under.
902
1047
  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 });
903
1048
  const js = st.jobs[id] || {};
@@ -905,16 +1050,44 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
905
1050
  const lock = jobLockInfo(ws, id);
906
1051
  const intent = js.attempt || (lock ? (js.lastRun?.execution !== undefined ? { execution: js.lastRun.execution } : {}) : undefined);
907
1052
  const { history, recentRuns } = projectHistory(js);
908
- 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 } : {}) };
1053
+ const { workspace: _w, ...stored } = def; void _w;
1054
+ const base = { ...stored, 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
+ return scheduleRow(ws, entry ?? localEntry("schedule", { ...def, id: name }, { dep: ws }), base, ctx);
1056
+ }
1057
+ /** A schedule's list row: its stored facts, the fields every trigger and schedule row share
1058
+ * (lib/automations.mjs baseRow) and the schedule's own: `kind` is its run (spawn, command, wake,
1059
+ * operation), the soul it spawns, the task, the cron and tz, the last run and the next due. */
1060
+ function scheduleRow(ws, entry, base, ctx) {
1061
+ const { enabled: _e, ...rest } = base; void _e;
1062
+ const def = entry.definition || {};
1063
+ const soul = def.kind === "spawn" ? def.agent ?? null : null;
1064
+ const local = entry.member === "local";
1065
+ return {
1066
+ ...rest, ...baseRow(entry),
1067
+ // A local schedule keeps its bare id (the 0.28 contract every consumer reads); its qualified
1068
+ // form is qualifiedId. A workspace schedule's id IS qualified: <member>/<id>.
1069
+ id: local ? entry.name : entry.id, qualifiedId: entry.id,
1070
+ kind: base.kind ?? def.kind ?? null,
1071
+ soul: soul ? { name: soul, origin: soulOriginOf(ctx?.snapshot?.souls, soul) } : null,
1072
+ task: def.task ?? null, cron: def.cron ?? null, tz: def.tz ?? null,
1073
+ teams: [], launchConfig: def.launchConfig ?? null, harness: def.harness ?? null, model: def.model ?? null, concurrency: null,
1074
+ lastRun: base.lastRun ?? null, nextDue: entry.placement.runsHere ? base.nextRun ?? null : null,
1075
+ enabled: entry.enabled !== false,
1076
+ };
909
1077
  }
910
- export function listSchedules(ws, io, { now = new Date() } = {}) {
1078
+ export function listSchedules(ws, io, { now = new Date(), ctx = null } = {}) {
911
1079
  const defs = readDefinitions(ws), st = readState(ws);
1080
+ const all = effectiveDefinitions(ws, ctx);
912
1081
  // One job's bad identity or history does not fail the others: it is reported as its own row.
913
- const schedules = Object.keys(defs.jobs).sort().map((id) => {
914
- try { return describe(ws, id, io, { defs, st, now }); }
915
- 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: [] }; }
916
- });
917
- return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
1082
+ const row = (qid) => {
1083
+ try { return describe(ws, qid, io, { defs: all, st, now, ctx }); }
1084
+ catch (e) { return { id: splitId(qid).scope === "local" ? splitId(qid).name : qid, qualifiedId: qid, name: splitId(qid).name, 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: [] }; }
1085
+ };
1086
+ const local = Object.keys(defs.jobs).filter((id) => defs.jobs[id]?.kind !== "trigger").sort().map((id) => row(localId(id)));
1087
+ const workspace = (ctx?.schedules || []).map((a) => row(a.id));
1088
+ // Triggers share the definitions file but not this listing: a pointer, so they are never invisible.
1089
+ const triggers = Object.values(defs.jobs).filter((d) => d?.kind === "trigger").length + (ctx?.triggers || []).length;
1090
+ return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), host: { name: ctx?.host?.name ?? null, ghUser: ctx?.ghUsers ? ctx.ghUsers() : {} }, schedules: [...local, ...workspace], triggers: { count: triggers, command: "oats trigger list" }, snapshot: ctx?.snapshot ? { takenAt: ctx.snapshot.takenAt, problems: (ctx.snapshot.problems || []).length } : null, scheduler: schedulerStatus(ws, io) };
918
1091
  }
919
1092
  export function addSchedule(ws, spec, io) {
920
1093
  const def = validateDefinition(ws, spec);
@@ -924,41 +1097,44 @@ export function addSchedule(ws, spec, io) {
924
1097
  const now = new Date().toISOString();
925
1098
  defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
926
1099
  writeDefinitions(ws, defs);
927
- return describe(ws, def.id, io);
1100
+ return describe(ws, localId(def.id), io);
928
1101
  });
929
1102
  }
930
1103
  /** Update under the host lock (so the busy check cannot race a tick's
931
1104
  * admission) and the scope lock. What a run is tracked or reconciled by
932
1105
  * (kind, agent, agentsRoot, repo, purpose, home, cwd, argv) cannot change
933
1106
  * while the job holds a slot or has an unresolved attempt; timing and text can. */
934
- export function updateSchedule(ws, id, spec, io) {
1107
+ export function updateSchedule(ws, qid, spec, io) {
1108
+ const id = localScheduleId(qid, "update");
935
1109
  const def = validateDefinition(ws, { ...spec, id });
936
1110
  return withHostLock(() => withScopeLock(ws, () => {
937
1111
  const defs = readDefinitions(ws);
938
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1112
+ if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
939
1113
  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]);
940
1114
  const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
941
1115
  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)`);
942
1116
  defs.jobs[id] = { ...def, createdAt: defs.jobs[id].createdAt, updatedAt: new Date().toISOString() };
943
1117
  writeDefinitions(ws, defs);
944
- return describe(ws, id, io);
1118
+ return describe(ws, localId(id), io);
945
1119
  }), { retryMs: 3000 });
946
1120
  }
947
- export function setEnabled(ws, id, enabled, io) {
1121
+ export function setEnabled(ws, qid, enabled, io) {
1122
+ const id = localScheduleId(qid, "change");
948
1123
  return withScopeLock(ws, () => {
949
1124
  const defs = readDefinitions(ws);
950
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1125
+ if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
951
1126
  defs.jobs[id] = { ...defs.jobs[id], enabled, updatedAt: new Date().toISOString() };
952
1127
  writeDefinitions(ws, defs);
953
- return describe(ws, id, io);
1128
+ return describe(ws, localId(id), io);
954
1129
  });
955
1130
  }
956
1131
  /** Remove under the host lock (state changes under a live tick are refused
957
1132
  * by the lock, not raced). */
958
- export function removeSchedule(ws, id, { force = false } = {}) {
1133
+ export function removeSchedule(ws, qid, { force = false } = {}) {
1134
+ const id = localScheduleId(qid, "remove");
959
1135
  return withHostLock(() => withScopeLock(ws, () => {
960
1136
  const defs = readDefinitions(ws);
961
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
1137
+ if (!defs.jobs[id] || defs.jobs[id].kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}${defs.jobs[id] ? " (it is a trigger: use oats trigger)" : ""}`);
962
1138
  const st = readState(ws), job = st.jobs[id];
963
1139
  if ((jobLockInfo(ws, id) || job?.attempt || job?.lastRun?.outcome === "unknown") && !force) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has unresolved effects; reconcile first, or --force forgets the job without stopping anything`);
964
1140
  delete defs.jobs[id];
@@ -968,6 +1144,13 @@ export function removeSchedule(ws, id, { force = false } = {}) {
968
1144
  return { removed: id };
969
1145
  }));
970
1146
  }
1147
+ /** The stored id of a LOCAL schedule argument; a workspace schedule is changed in Git (enabled or
1148
+ * disabled on this host through oats-local.yaml schedules.disabled). */
1149
+ function localScheduleId(qid, verb) {
1150
+ const { scope, name } = splitId(qid);
1151
+ if (scope !== "local") throw automationError("E_AUTOMATION_WORKSPACE", `${qid} is a workspace schedule, defined in Git: ${verb} its file there${verb === "remove" ? `, or stop it on this host: oats schedule disable ${qid}` : ""}`, { id: qid });
1152
+ return name;
1153
+ }
971
1154
  /** Save the wake job for a freshly spawned instance (id wake-<instance>). */
972
1155
  export function saveWakeForHome(ws, { instance, home, wake }) {
973
1156
  const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");