@awebai/oats 0.27.2 → 0.28.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 (37) hide show
  1. package/bin/oats.mjs +185 -26
  2. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
  3. package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
  4. package/capabilities/oats-okf/injects/okf.md +29 -21
  5. package/capabilities/oats-okf/lib/config.mjs +5 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +500 -0
  7. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  8. package/capabilities/oats-okf/lib/io.mjs +9 -2
  9. package/capabilities/oats-okf/lib/sources.mjs +15 -53
  10. package/capabilities/oats-okf/lib/stores.mjs +10 -7
  11. package/capabilities/oats-okf/lib/worker.mjs +9 -1
  12. package/capabilities/oats-okf/oats.json +13 -4
  13. package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  16. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  17. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  18. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  19. package/docs/desktop-cli-api.md +93 -7
  20. package/docs/oats-local.schema.json +2 -1
  21. package/docs/oats-package.schema.json +39 -0
  22. package/docs/packages.md +67 -3
  23. package/docs/release-notes/v0.28.0.md +144 -0
  24. package/docs/schedules.md +99 -1
  25. package/docs/souls-and-instances.md +7 -3
  26. package/docs/workspaces.md +8 -2
  27. package/lib/core.mjs +22 -4
  28. package/lib/instance-inspect.mjs +4 -4
  29. package/lib/instance-resolution.mjs +62 -18
  30. package/lib/materialize.mjs +13 -0
  31. package/lib/packages.mjs +90 -6
  32. package/lib/resolve.mjs +20 -2
  33. package/lib/schedule.mjs +24 -11
  34. package/lib/triggers.mjs +545 -0
  35. package/lib/workspace.mjs +80 -3
  36. package/package-catalog.json +1 -1
  37. package/package.json +1 -1
package/lib/schedule.mjs CHANGED
@@ -29,6 +29,7 @@ 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";
32
33
  import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
33
34
 
34
35
  export const SCHEDULE_FILE = "oats-schedules.json";
@@ -65,6 +66,7 @@ const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/
65
66
  export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
66
67
  export function shapeError(def) {
67
68
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
69
+ if (def.kind === "trigger") return "a trigger is managed by oats trigger, not oats schedule";
68
70
  if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
69
71
  if (typeof def.cron !== "string" || typeof def.tz !== "string") return "cron and tz must be strings";
70
72
  if (def.kind === "spawn" && typeof def.agent !== "string") return "agent must be a string";
@@ -246,7 +248,7 @@ export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join
246
248
  /** Registry read-modify-write is serialized on its own short lock. */
247
249
  function withRegistryLock(fn) { return withDirLock(join(hostScheduleDir(), "registry.lock"), "the host schedule registry", fn, { retryMs: 3000 }); }
248
250
  /** 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 }); }
251
+ export function withScopeLock(ws, fn) { return withDirLock(join(stateDir(ws), "scope.lock"), `schedules of ${ws}`, fn, { retryMs: 3000 }); }
250
252
 
251
253
  export function acquireJobLock(ws, id, owner) {
252
254
  const dir = lockDir(ws, id);
@@ -288,6 +290,7 @@ function validateMessage(message, field) {
288
290
  /** Validate and normalize one definition against its scope. */
289
291
  export function validateDefinition(ws, def, { checkAgent = true } = {}) {
290
292
  if (!def || typeof def !== "object" || Array.isArray(def)) throw scheduleError("E_SCHEDULE_INVALID", "definition must be an object", { field: "definition" });
293
+ 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
294
  const id = def.id;
292
295
  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
296
  const enabled = def.enabled === undefined ? true : def.enabled;
@@ -431,13 +434,13 @@ function scheduleBlock(def, minute) {
431
434
  * may be invoked from inside an unrelated instance; dispatch belongs to the
432
435
  * job's cwd and explicit selectors, never to that caller), host configuration
433
436
  * such as OATS_HOME_DIR and credentials kept. Shared by command and spawn jobs. */
434
- function childEnv() {
437
+ export function childEnv() {
435
438
  const env = { ...process.env };
436
439
  for (const key of [...RESERVED_LAUNCH_ENV,
437
440
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
438
441
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
439
442
  "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
440
- "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
443
+ "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE", "OATS_TRIGGER_EVENT_FILE",
441
444
  ]) delete env[key];
442
445
  return env;
443
446
  }
@@ -671,6 +674,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
671
674
  // are gone), then decide launches: a job ordered earlier must not see a
672
675
  // slot still held by a home that has already ended.
673
676
  // `candidates` (the host tick) means the host already observed every scope.
677
+ // Triggers (kind "trigger") live in the same file and are evaluated by tickTriggers, not here.
678
+ for (const [id, def] of Object.entries(defs.jobs)) if (def && def.kind === "trigger") delete defs.jobs[id];
674
679
  if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
675
680
  if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
676
681
  // Least recently LAUNCHED first: only an actual harness launch counts, a
@@ -778,7 +783,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
778
783
  try {
779
784
  considered.push(...tickWorkspace(ws, { now, io, reg, wsList, dryRun, observeOnly: true }));
780
785
  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]) });
786
+ for (const [id, def] of Object.entries(defs.jobs)) if (def?.kind !== "trigger") jobs.push({ ws, id, key: launchOrderKey(st.jobs[id]) });
782
787
  } catch (e) { considered.push({ workspace: ws, action: "error", error: e.message }); }
783
788
  }
784
789
  jobs.sort((a, b) => a.key.localeCompare(b.key) || a.ws.localeCompare(b.ws) || a.id.localeCompare(b.id));
@@ -786,6 +791,11 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
786
791
  try { considered.push(...tickWorkspace(j.ws, { now, io, reg, wsList, dryRun, candidates: [j.id] })); }
787
792
  catch (e) { considered.push({ workspace: j.ws, id: j.id, action: "error", error: e.message }); }
788
793
  }
794
+ // Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
795
+ for (const ws of wsList) {
796
+ try { considered.push(...tickTriggers(ws, { now, io, dryRun })); }
797
+ catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
798
+ }
789
799
  if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
790
800
  return { tickedAt: now.toISOString(), minute: minuteKey(minuteStart(now)), considered, scheduler: schedulerStatus(undefined, io) };
791
801
  };
@@ -797,7 +807,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
797
807
  export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
798
808
  const defs = readDefinitions(ws);
799
809
  const def = defs.jobs[id];
800
- if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
810
+ if (!def || def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
801
811
  if (!def.enabled && !force) throw scheduleError("E_SCHEDULE_DISABLED", `schedule ${id} is disabled; enable it or pass --force`);
802
812
  const reg = readRegistry();
803
813
  const wsList = reg.workspaces.includes(resolve(ws)) ? reg.workspaces : [...reg.workspaces, resolve(ws)];
@@ -824,7 +834,7 @@ export function runNow(ws, id, { now = new Date(), io, force = false } = {}) {
824
834
  export function reconcile(ws, id, { io, now = new Date(), clear = false } = {}) {
825
835
  const defs = readDefinitions(ws);
826
836
  const def = defs.jobs[id];
827
- if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
837
+ if (!def || def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
828
838
  return withHostLock(() => {
829
839
  const st = readState(ws);
830
840
  const js = st.jobs[id] || (st.jobs[id] = {});
@@ -898,6 +908,7 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
898
908
  defs ||= readDefinitions(ws); st ||= readState(ws);
899
909
  const def = defs.jobs[id];
900
910
  if (!def) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
911
+ if (def.kind === "trigger") throw scheduleError("E_SCHEDULE_UNKNOWN", `${id} is a trigger, not a schedule — use oats trigger show ${id}`);
901
912
  // Subject truth: the definition's own id must be the key it is stored under.
902
913
  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
914
  const js = st.jobs[id] || {};
@@ -910,11 +921,13 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
910
921
  export function listSchedules(ws, io, { now = new Date() } = {}) {
911
922
  const defs = readDefinitions(ws), st = readState(ws);
912
923
  // 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) => {
924
+ const schedules = Object.keys(defs.jobs).filter((id) => defs.jobs[id]?.kind !== "trigger").sort().map((id) => {
914
925
  try { return describe(ws, id, io, { defs, st, now }); }
915
926
  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
927
  });
917
- return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, scheduler: schedulerStatus(ws, io) };
928
+ // Triggers share the definitions file but not this listing: a pointer, so they are never invisible.
929
+ const triggers = Object.values(defs.jobs).filter((d) => d?.kind === "trigger").length;
930
+ return { scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, integrity: scheduleIntegrity(ws), schedules, triggers: { count: triggers, command: "oats trigger list" }, scheduler: schedulerStatus(ws, io) };
918
931
  }
919
932
  export function addSchedule(ws, spec, io) {
920
933
  const def = validateDefinition(ws, spec);
@@ -935,7 +948,7 @@ export function updateSchedule(ws, id, spec, io) {
935
948
  const def = validateDefinition(ws, { ...spec, id });
936
949
  return withHostLock(() => withScopeLock(ws, () => {
937
950
  const defs = readDefinitions(ws);
938
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
951
+ 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
952
  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
953
  const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
941
954
  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)`);
@@ -947,7 +960,7 @@ export function updateSchedule(ws, id, spec, io) {
947
960
  export function setEnabled(ws, id, enabled, io) {
948
961
  return withScopeLock(ws, () => {
949
962
  const defs = readDefinitions(ws);
950
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
963
+ 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
964
  defs.jobs[id] = { ...defs.jobs[id], enabled, updatedAt: new Date().toISOString() };
952
965
  writeDefinitions(ws, defs);
953
966
  return describe(ws, id, io);
@@ -958,7 +971,7 @@ export function setEnabled(ws, id, enabled, io) {
958
971
  export function removeSchedule(ws, id, { force = false } = {}) {
959
972
  return withHostLock(() => withScopeLock(ws, () => {
960
973
  const defs = readDefinitions(ws);
961
- if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
974
+ 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
975
  const st = readState(ws), job = st.jobs[id];
963
976
  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
977
  delete defs.jobs[id];