@awebai/oats 0.27.1 → 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 (39) 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/capabilities.md +3 -1
  17. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  18. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  19. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  20. package/docs/desktop-cli-api.md +99 -7
  21. package/docs/oats-local.schema.json +2 -1
  22. package/docs/oats-package.schema.json +39 -0
  23. package/docs/packages.md +67 -3
  24. package/docs/release-notes/v0.27.2.md +51 -0
  25. package/docs/release-notes/v0.28.0.md +144 -0
  26. package/docs/schedules.md +99 -1
  27. package/docs/souls-and-instances.md +19 -3
  28. package/docs/workspaces.md +8 -2
  29. package/lib/core.mjs +124 -10
  30. package/lib/instance-inspect.mjs +4 -4
  31. package/lib/instance-resolution.mjs +62 -18
  32. package/lib/materialize.mjs +13 -0
  33. package/lib/packages.mjs +90 -6
  34. package/lib/resolve.mjs +20 -2
  35. package/lib/schedule.mjs +24 -11
  36. package/lib/triggers.mjs +545 -0
  37. package/lib/workspace.mjs +80 -3
  38. package/package-catalog.json +1 -1
  39. package/package.json +1 -1
@@ -28,7 +28,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
28
28
  import { tmpdir } from "node:os";
29
29
  import { spawnSync } from "node:child_process";
30
30
  import { randomBytes } from "node:crypto";
31
- import { readLock, LOCK_FILE, readPackageManifests } from "./packages.mjs";
31
+ import { readLock, LOCK_FILE, readPackageManifests, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
32
32
  import * as defaultRemote from "./remote.mjs";
33
33
  import { parseRepoRef } from "./remote.mjs";
34
34
 
@@ -77,30 +77,58 @@ export function parseProviderFlags(pairs) {
77
77
  return JSON.parse(JSON.stringify(out));
78
78
  }
79
79
 
80
- /** Find the soul named `name` in a discovery: confirmed members first (by
81
- * `repo/name` or bare name when unique), then external souls. */
80
+ /** The qualified name of a discovered soul: `<package>/<soul>` for a package soul,
81
+ * `<member name>/<soul>` for a member's, `<source repo name>/<soul>` for an external one. */
82
+ export function qualifiedSoulName(entry) {
83
+ if (typeof entry?.package === "string") return `${entry.package}/${entry.name}`;
84
+ return `${memberNameOf(entry?.repoKey ?? "")}/${entry?.name}`;
85
+ }
86
+
87
+ /** Find the soul named `name` in a discovery: confirmed members, external souls and package
88
+ * souls. A bare name must be unique across all three (else E_SOUL_AMBIGUOUS naming each
89
+ * qualified form); `<repo>/<soul>` names a member (its key, a key suffix or its member name)
90
+ * or an external source, `<package>/<soul>` a package soul. */
82
91
  export function findSoulEntry(discovery, name) {
83
92
  const [repoPart, soulPart] = name.includes("/") && !name.startsWith("/") ? [name.slice(0, name.lastIndexOf("/")), name.slice(name.lastIndexOf("/") + 1)] : [null, name];
84
93
  const hits = [];
85
94
  // A standalone view's one row is the repo's own (unconfirmed by definition — the
86
95
  // workspace could not be read); resolveSoul admits exactly that case.
87
96
  const standaloneOwn = discovery.standalone === true ? discovery.key : null;
97
+ const memberMatches = (key) => !repoPart || key === repoPart || key.endsWith(repoPart) || memberNameOf(key) === repoPart;
88
98
  for (const m of discovery.members || []) {
89
99
  if (!m.confirmed && m.key !== standaloneOwn) continue;
90
100
  for (const s of m.souls || []) {
91
- if (s.name !== soulPart) continue;
92
- if (repoPart && !m.key.endsWith(repoPart) && m.key !== repoPart) continue;
101
+ if (s.name !== soulPart || !memberMatches(m.key)) continue;
93
102
  hits.push({ ...s, repoKey: m.key, memberCommit: m.commit, external: false });
94
103
  }
95
104
  }
96
105
  for (const x of discovery.external || []) {
97
106
  if (x.soul?.name === soulPart && (!repoPart || (x.source && String(x.source).includes(repoPart)))) hits.push({ ...x.soul, repoKey: x.soul.repoKey ?? parseRepoRef(x.source.replace(/@.*$/, "")).key, commit: x.commit, external: true });
98
107
  }
99
- if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members or external souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key) });
100
- if (hits.length > 1) throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} exists in ${hits.length} repos; name it as <repo>/${soulPart}`, { name, repos: hits.map((h) => h.repoKey) });
108
+ for (const s of discovery.packageSouls || []) {
109
+ if (s.name === soulPart && (!repoPart || repoPart === s.package)) hits.push({ ...s, external: false });
110
+ }
111
+ if (hits.length === 0) throw err("E_SOUL_UNKNOWN", `no soul ${JSON.stringify(name)} among the confirmed members, external souls or package souls of this workspace`, { name, members: (discovery.members || []).filter((m) => m.confirmed).map((m) => m.key), packages: [...new Set((discovery.packageSouls || []).map((s) => s.package))].sort() });
112
+ if (hits.length > 1) {
113
+ const qualified = hits.map(qualifiedSoulName);
114
+ throw err("E_SOUL_AMBIGUOUS", `soul ${JSON.stringify(name)} is ${hits.length} souls (${qualified.join(", ")}); name one of them`, { name, repos: hits.map((h) => h.repoKey), qualified });
115
+ }
116
+ if (Array.isArray(hits[0].collides)) throw err("E_SOUL_AMBIGUOUS", `package souls ${hits[0].collides.join(" and ")} would share the agent directory agents/${hits[0].agentName}/ — keep one of the packages in the workspace's packages:`, { name, agentDir: hits[0].agentName, qualified: hits[0].collides });
101
117
  return hits[0];
102
118
  }
103
119
 
120
+ /** The agents-root directory of a discovered soul: a package soul's is `<package>--<soul>`
121
+ * (packageSoulAgentName), every other soul's is its name. */
122
+ export function agentDirOf(entry) { return typeof entry?.agentName === "string" ? entry.agentName : entry?.name; }
123
+
124
+ /** `oats-local.yaml` `souls.disabled` (not run on this machine): an entry names a soul by its
125
+ * bare name (every soul of that name) or its qualified name. → the matching entry or null. */
126
+ export function disabledEntry(local, soulEntry) {
127
+ const list = Array.isArray(local?.souls?.disabled) ? local.souls.disabled : [];
128
+ const qualified = qualifiedSoulName(soulEntry);
129
+ return list.find((d) => d === soulEntry.name || d === qualified) ?? null;
130
+ }
131
+
104
132
  /**
105
133
  * A home's LIVE eligible teams (teams contract 2026-09-25, decision 6). Teams are messaging
106
134
  * state, not frozen composition: the soul's labels are read from its repository NOW and each
@@ -117,6 +145,8 @@ export async function liveTeams(home, meta, { remoteOptions, remote } = {}) {
117
145
  const soul = meta?.workspace?.soul;
118
146
  // A capability agent's soul is its providing module's: no team labels, nothing to re-read.
119
147
  if (!soul || typeof soul.repoKey !== "string" || typeof meta.agent !== "string") return recorded("no-workspace-soul");
148
+ // A package soul's labels are pinned with the package: the spawn-time record is its answer.
149
+ if (soul.package && typeof soul.package === "object") return recorded("package-soul");
120
150
  const deployment = dirname(dirname(dirname(dirname(resolvePath(home)))));
121
151
  try {
122
152
  const found = loadLocal(deployment);
@@ -162,9 +192,13 @@ function swapSoulPointer(soulDir, target) {
162
192
  /** Fetch a prepared soul's source tree (soul.yaml + AGENTS.md, CLAUDE.md → AGENTS.md) into `dest`. */
163
193
  async function fetchSoulSource(prepared, dest) {
164
194
  const e = prepared.soulEntry;
165
- const ref = e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
195
+ const ref = typeof e.ref === "string" ? e.ref : e.repoKey.startsWith("local/") ? e.repoKey.slice("local/".length) : `git:${e.repoKey}`;
166
196
  // A soul's CLAUDE.md → AGENTS.md alias is the one symlink a soul source may carry.
167
- await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: (p) => p === "CLAUDE.md" });
197
+ const { digest } = await fetchRemoteTree(ref, e.commit, e.path, dest, { ...(prepared.remoteOptions || {}), allowSymlinks: SOUL_ALIAS_SYMLINK });
198
+ // A package soul is what the lock recorded at sync: the same bytes at the locked commit.
199
+ if (typeof e.package === "string" && digest !== e.digest) {
200
+ throw err("E_PACKAGE_INTEGRITY", `package soul ${e.qualifiedName} v${e.version} @ ${String(e.commit).slice(0, 12)}: content digest ${digest} does not match the locked ${e.digest} — run \`oats sync\` (a lock that was edited: remove the entry and sync again)`, { package: e.package, soul: e.name, version: e.version, commit: e.commit, why: "soul-digest", locked: e.digest, observed: digest });
201
+ }
168
202
  if (!existsSync(join(dest, "soul.yaml")) || !existsSync(join(dest, "AGENTS.md"))) throw err("E_SOUL_INCOMPLETE", `soul ${e.name} at ${e.repoKey}@${String(e.commit).slice(0, 12)} lacks soul.yaml or AGENTS.md`, { repoKey: e.repoKey, commit: e.commit, path: e.path });
169
203
  if (!existsSync(join(dest, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(dest, "CLAUDE.md"));
170
204
  }
@@ -176,7 +210,7 @@ async function fetchSoulSource(prepared, dest) {
176
210
  * that temporary copy; `fetched` says whether the preview had to fetch. */
177
211
  export async function previewWorkspaceSoul(prepared, agentsRoot) {
178
212
  const e = prepared.soulEntry;
179
- const commitDir = join(agentsRoot, e.name, SOULS_DIR, commit12(e.commit));
213
+ const commitDir = join(agentsRoot, agentDirOf(e), SOULS_DIR, commit12(e.commit));
180
214
  if (existsSync(join(commitDir, "soul.yaml")) && existsSync(join(commitDir, "AGENTS.md"))) return { soulDir: realpathSync(commitDir), fetched: false, cleanup: () => {} };
181
215
  const tmp = realpathSync(mkdtempSync(join(tmpdir(), "oats-preview-soul-")));
182
216
  const cleanup = () => rmSync(tmp, { recursive: true, force: true });
@@ -202,7 +236,7 @@ export async function previewWorkspaceSoul(prepared, agentsRoot) {
202
236
  * Returns the PER-COMMIT directory (what a home should link). */
203
237
  export async function ensureWorkspaceSoul(prepared, agentsRoot) {
204
238
  const e = prepared.soulEntry;
205
- const agentDir = join(agentsRoot, e.name); const soulDir = join(agentDir, "soul");
239
+ const agentDir = join(agentsRoot, agentDirOf(e)); const soulDir = join(agentDir, "soul");
206
240
  const soulsDir = join(agentDir, SOULS_DIR);
207
241
  const stamp = join(agentDir, SOUL_SOURCE_STAMP);
208
242
  const readStamp = () => { try { return JSON.parse(readFileSync(stamp, "utf8")); } catch { return null; } };
@@ -248,7 +282,8 @@ export async function ensureWorkspaceSoul(prepared, agentsRoot) {
248
282
  if (soulPointerTarget(soulDir) !== target) swapSoulPointer(soulDir, target);
249
283
  const cur = readStamp();
250
284
  if (!cur || cur.repoKey !== e.repoKey || cur.commit !== e.commit || cur.path !== e.path) {
251
- writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
285
+ const pkg = typeof e.package === "string" ? { package: e.package, version: e.version } : {};
286
+ writeFileSync(stamp, JSON.stringify({ repoKey: e.repoKey, commit: e.commit, path: e.path, ...pkg, fetchedAt: new Date().toISOString() }, null, 2) + "\n");
252
287
  }
253
288
  return target;
254
289
  }
@@ -389,8 +424,10 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
389
424
  const local = found.local;
390
425
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
391
426
  const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
392
- const discovery = discoveryOverride ?? await discoverOrStandalone(local, { remoteOptions, remote });
427
+ const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
393
428
  const soulEntry = findSoulEntry(discovery, soulName);
429
+ const disabled = disabledEntry(local, soulEntry);
430
+ if (disabled !== null) throw err("E_SOUL_DISABLED", `soul ${qualifiedSoulName(soulEntry)} is disabled on this machine (oats-local.yaml souls.disabled: ${disabled}) — remove it from that list to spawn it here`, { name: soulEntry.name, qualifiedName: qualifiedSoulName(soulEntry), entry: disabled });
394
431
  const resolution = await resolveSoul(discovery, soulEntry, { local, lock, spawn, remoteOptions, remote });
395
432
  return { local, deployment, lock, discovery, soulEntry, resolution, remoteOptions, spawn };
396
433
  }
@@ -402,6 +439,11 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
402
439
  const ACCESS_REASONS = new Set(["auth", "not-found"]);
403
440
  const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASONS.has(e?.details?.reason ?? e?.provenance?.reason);
404
441
 
442
+ /** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
443
+ export function deploymentLock(deployment) {
444
+ return existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
445
+ }
446
+
405
447
  /** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
406
448
  * repo must declare that workspace (oats-membership.yaml). A lone repo with no
407
449
  * backlink is not a member of anything and never becomes a capability source. */
@@ -424,21 +466,23 @@ function requireMembership(repo, ref) {
424
466
  * readable in this access context (auth / not-found).
425
467
  * A transient failure reading the host (network, timeout) is rethrown as-is.
426
468
  */
427
- export async function discoverOrStandalone(local, { remoteOptions, remote } = {}) {
469
+ export async function discoverOrStandalone(local, { lock, deployment, remoteOptions, remote } = {}) {
428
470
  const ro = { remoteOptions, remote };
471
+ // Package souls are listed from the deployment's lock (the `lock` given, else `<deployment>/oats-lock.json`).
472
+ if (lock === undefined) lock = typeof deployment === "string" ? deploymentLock(deployment) : null;
429
473
  if (typeof local.standalone === "string" && local.standalone) {
430
474
  const repo = await discoverRepo(local.standalone, ro);
431
475
  requireMembership(repo, local.standalone);
432
476
  return { ...standaloneRepo(local.standalone, repo.commit, repo, { remote }), standaloneReason: "explicit" };
433
477
  }
434
- try { return await discoverWorkspace(local.workspace, { local, ...ro }); }
478
+ try { return await discoverWorkspace(local.workspace, { local, lock, ...ro }); }
435
479
  catch (e) {
436
480
  if (e?.code !== "E_WORKSPACE_SCHEMA" || e?.details?.notAHost !== true) throw e;
437
481
  // The ref is a repository without oats-workspace.yaml: is it a member whose
438
482
  // workspace we cannot read? Then the standalone view is what the operator gets.
439
483
  const repo = await discoverRepo(local.workspace, ro);
440
484
  if (!repo.membership) throw e;
441
- try { return await discoverWorkspace(repo.membership.workspace, { local, ...ro }); }
485
+ try { return await discoverWorkspace(repo.membership.workspace, { local, lock, ...ro }); }
442
486
  catch (inner) {
443
487
  // Only an ACCESS failure on the host means "standalone"; anything else
444
488
  // (network, timeout, a broken workspace file) is the caller's to see.
@@ -499,7 +543,7 @@ export async function prepareCapabilityAgent(contextDir, agent, { discovery, pkg
499
543
  const local = found.local;
500
544
  const deployment = dirname(found.path);
501
545
  const lock = existsSync(join(deployment, LOCK_FILE)) ? readLock(deployment) : null;
502
- const view = discovery ?? await discoverOrStandalone(local, { remoteOptions });
546
+ const view = discovery ?? await discoverOrStandalone(local, { lock, remoteOptions });
503
547
  const cap = agent.capability;
504
548
  let module, payload, origins, localModules = null;
505
549
  const anchor = pkg ? null : agent._manifestSource;
@@ -652,7 +696,7 @@ export async function resolvePackageCapabilityAgent(contextDir, name, { remoteOp
652
696
  const found = loadLocal(contextDir);
653
697
  const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
654
698
  const remote = defaultRemote;
655
- const view = discovery === undefined ? await discoverOrStandalone(found.local, { remoteOptions }) : discovery;
699
+ const view = discovery === undefined ? await discoverOrStandalone(found.local, { deployment, remoteOptions }) : discovery;
656
700
  // A confirmed member's own (non-private) capability first: membership is its trust
657
701
  // decision, exactly as for a soul's `from: <member>` module.
658
702
  for (const row of view?.members || []) {
@@ -584,12 +584,25 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
584
584
  * (reason "unconfirmed" / the row's reason). A standalone view's own member row is unconfirmed
585
585
  * by construction (reason "cannot-read") yet carries the repo's current commit — it is compared,
586
586
  * not reported missing. Returns null for a home without a recorded workspace soul (classic).
587
+ * A PACKAGE soul (`workspace.soul.package`) is compared with the discovery's package souls: the
588
+ * pin moved → "moved"; the package no longer locked/declared → "missing" ("package-absent"); the
589
+ * soul no longer shipped by the package → "missing" ("soul-absent").
587
590
  */
588
591
  export function soulDriftOf(instanceJson, discovery) {
589
592
  const soul = plainObject(instanceJson?.workspace) && plainObject(instanceJson.workspace.soul) ? instanceJson.workspace.soul : null;
590
593
  if (!soul || typeof soul.repoKey !== "string") return null;
591
594
  const name = typeof instanceJson.agent === "string" ? instanceJson.agent : (typeof instanceJson.soul === "string" ? instanceJson.soul : null);
592
595
  const commit = typeof soul.commit === "string" ? soul.commit : null;
596
+ if (plainObject(soul.package) && typeof soul.package.id === "string") {
597
+ // A package soul: "moved" means the package pin moved (another version/commit is locked now).
598
+ const pkg = { package: soul.package.id, version: soul.package.version ?? null };
599
+ const soulName = typeof soul.name === "string" ? soul.name : name;
600
+ const base = { name: soulName, repoKey: soul.repoKey, commit, team: soul.team ?? null, ...pkg };
601
+ const listed = Array.isArray(discovery?.packageSouls) ? discovery.packageSouls.filter((s) => s && s.package === soul.package.id) : [];
602
+ const now = listed.find((s) => s.name === soulName) || null;
603
+ if (!now) return { ...base, current: null, status: "missing", reason: listed.length ? "soul-absent" : "package-absent" };
604
+ return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
605
+ }
593
606
  const members = Array.isArray(discovery?.members) ? discovery.members : [];
594
607
  const member = members.find((m) => m && m.key === soul.repoKey) || null;
595
608
  const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
package/lib/packages.mjs CHANGED
@@ -28,7 +28,8 @@
28
28
  * version: "<version string, no leading v>",
29
29
  * commit: "<full 40-hex OID>",
30
30
  * integrity: "sha256-<hex>", // contentDigest of the package tree at <path>
31
- * capabilities: ["<cap name>", …] // sorted
31
+ * capabilities: ["<cap name>", …], // sorted
32
+ * souls: [{ name, path, digest }] // package souls (0.28.0), sorted by name; omitted when none
32
33
  * }
33
34
  * }
34
35
  * }
@@ -57,6 +58,11 @@ export const DEFAULT_PACKAGE_PATH = "oats-package";
57
58
 
58
59
  const OID_RE = /^[0-9a-f]{40}$/;
59
60
  const DIGEST_RE = /^sha256-[0-9a-f]{64}$/;
61
+ /** A soul name (docs/soul.schema.json#/$defs/slug): a package soul's name is its directory's. */
62
+ const SOUL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
63
+ /** The one symlink a soul source may carry: its CLAUDE.md → AGENTS.md alias (at the soul's root).
64
+ * Sync and spawn fetch a package soul with this same predicate, so their digests agree. */
65
+ export const SOUL_ALIAS_SYMLINK = (p) => p === "CLAUDE.md";
60
66
  const CONTRACT_DOC = "docs/design/2026-09-23-workspace-module-contracts.md";
61
67
 
62
68
  /** `oatsError` with details attached as BOTH `e.provenance` (today's field) and
@@ -143,6 +149,14 @@ export function validateLock(lock, { file } = {}) {
143
149
  if (typeof entry.commit !== "string" || !OID_RE.test(entry.commit)) throw bad("commit", "must be a full 40-hex OID");
144
150
  if (typeof entry.integrity !== "string" || !DIGEST_RE.test(entry.integrity)) throw bad("integrity", "must be sha256-<hex>");
145
151
  if (!Array.isArray(entry.capabilities) || entry.capabilities.some((c) => typeof c !== "string" || !c)) throw bad("capabilities", "must be an array of capability names");
152
+ if (entry.souls !== undefined) {
153
+ if (!Array.isArray(entry.souls)) throw bad("souls", "must be an array of { name, path, digest }");
154
+ for (const [i, soul] of entry.souls.entries()) {
155
+ if (!plainObject(soul) || typeof soul.name !== "string" || !SOUL_NAME_RE.test(soul.name)) throw bad(`souls/${i}/name`, "must be a soul name");
156
+ if (typeof soul.path !== "string" || !soul.path || soul.path.split("/").some((p) => p === ".." || p === "") || soul.path.startsWith("/")) throw bad(`souls/${i}/path`, "must be a relative path inside the package");
157
+ if (typeof soul.digest !== "string" || !DIGEST_RE.test(soul.digest)) throw bad(`souls/${i}/digest`, "must be sha256-<hex>");
158
+ }
159
+ }
146
160
  }
147
161
  return lock;
148
162
  }
@@ -168,6 +182,7 @@ export function canonicalLock(lock) {
168
182
  packages[id] = {
169
183
  source: e.source, ...(typeof e.url === "string" && e.url ? { url: e.url } : {}), path: e.path, version: e.version, commit: e.commit, integrity: e.integrity,
170
184
  capabilities: [...e.capabilities].sort(),
185
+ ...(Array.isArray(e.souls) && e.souls.length ? { souls: [...e.souls].sort((a, b) => byCodepoint(a.name, b.name)).map((x) => ({ name: x.name, path: x.path, digest: x.digest })) } : {}),
171
186
  };
172
187
  }
173
188
  return { lockfileVersion: LOCK_VERSION, packages };
@@ -319,7 +334,63 @@ export async function readPackageManifests(remote, remoteRef, commit, path, deta
319
334
  capabilities.push({ name: capManifest.capability, dir, manifest: capManifest });
320
335
  }
321
336
  capabilities.sort((a, b) => byCodepoint(a.name, b.name));
322
- return { manifest, capabilities };
337
+ // Package souls (0.28.0): ordinary soul directories, versioned and locked with the package. A soul's
338
+ // name is its directory's (as for a member's souls/<name>); soul.yaml is validated where it is
339
+ // discovered, AGENTS.md is required where it is locked (packageSoulDigests).
340
+ const souls = [];
341
+ if (manifest.souls !== undefined) {
342
+ if (!Array.isArray(manifest.souls)) throw oatsError("E_PACKAGE_MANIFEST", `package manifest at ${manifestPath}: "souls" must be a list of soul directories`, { ...details, path: manifestPath });
343
+ const seenSouls = new Map();
344
+ for (const rel of manifest.souls) {
345
+ assertRelPath(`${manifestPath} souls[]`, rel, { ...details, path: manifestPath });
346
+ const soulPath = posix.normalize(rel).replace(/\/+$/, "");
347
+ const name = posix.basename(soulPath);
348
+ if (!SOUL_NAME_RE.test(name)) throw oatsError("E_PACKAGE_MANIFEST", `${manifestPath} souls[]: ${JSON.stringify(rel)} — a soul's directory is its name, which must match ${SOUL_NAME_RE}`, { ...details, path: manifestPath, soul: rel });
349
+ if (seenSouls.has(name)) throw oatsError("E_PACKAGE_MANIFEST", `package ${manifest.package} declares soul ${JSON.stringify(name)} twice (${seenSouls.get(name)} and ${soulPath})`, { ...details, path: manifestPath, duplicate: name, other: seenSouls.get(name) });
350
+ seenSouls.set(name, soulPath);
351
+ souls.push({ name, path: soulPath, dir: pjoin(path, soulPath) });
352
+ }
353
+ souls.sort((a, b) => byCodepoint(a.name, b.name));
354
+ }
355
+ return { manifest, capabilities, souls };
356
+ }
357
+
358
+ /** Fetch each package soul at `commit` and return its lock record `{ name, path, digest }` — the digest
359
+ * is what `fetchRemoteTree` reports with SOUL_ALIAS_SYMLINK, the same fetch a spawn makes and verifies.
360
+ * A soul without soul.yaml or AGENTS.md is E_PACKAGE_MANIFEST. */
361
+ export async function packageSoulDigests(remote, remoteRef, commit, souls, details = {}) {
362
+ if (!souls.length) return [];
363
+ if (typeof remote.fetchRemoteTree !== "function") throw oatsError("E_PACKAGE_INTEGRITY", "remote must provide fetchRemoteTree() to compute a package soul's digest", { path: "/remote" });
364
+ const scratch = mkdtempSync(join(tmpdir(), "oats-pkg-soul-"));
365
+ try {
366
+ const out = [];
367
+ for (const soul of souls) {
368
+ const dest = join(scratch, soul.name);
369
+ let digest;
370
+ try { ({ digest } = await remote.fetchRemoteTree(remoteRef, commit, soul.dir, dest, { allowSymlinks: SOUL_ALIAS_SYMLINK })); }
371
+ catch (e) {
372
+ if (e?.code === "E_REMOTE_PATH_MISSING") throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} is missing at ${String(commit).slice(0, 12)}`, { ...details, soul: soul.name, path: soul.dir });
373
+ throw e;
374
+ }
375
+ // A fake remote reports a digest without copying: only a real copy can be checked for its files.
376
+ if (existsSync(dest)) {
377
+ for (const file of ["soul.yaml", "AGENTS.md"]) {
378
+ if (!existsSync(join(dest, file))) throw oatsError("E_PACKAGE_MANIFEST", `package soul ${soul.path} lacks ${file} at ${String(commit).slice(0, 12)} — a soul directory carries soul.yaml and AGENTS.md`, { ...details, soul: soul.name, path: soul.dir, missing: file });
379
+ }
380
+ }
381
+ out.push({ name: soul.name, path: soul.path, digest: assertDigest(`package soul ${soul.path} digest`, digest, details) });
382
+ }
383
+ return out;
384
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
385
+ }
386
+
387
+ /** The repo ref a lock entry is read from without a catalog: its recorded url, else the key of a
388
+ * `git:<key>@<ref>` source. → string | null (a catalog entry written without a url). */
389
+ export function lockedPackageRef(entry) {
390
+ if (typeof entry?.url === "string" && entry.url) return entry.url;
391
+ const m = /^git:(.+)@([^@]+)$/.exec(String(entry?.source ?? ""));
392
+ if (!m) return null;
393
+ return m[1].startsWith("local/") ? m[1].slice("local/".length) : `git:${m[1]}`;
323
394
  }
324
395
 
325
396
  /** Content digest of the package tree at `path`: the digest `fetchRemoteTree` reports for a
@@ -419,23 +490,36 @@ export async function resolvePackages(workspace, { catalog = {}, lock = emptyLoc
419
490
  // The lock's capability list must still be what the package declares at that commit —
420
491
  // spawn refuses a mismatch (E_PACKAGE_INTEGRITY why:capabilities), so sync must too, or
421
492
  // a bad list would pass every sync and fail every spawn.
422
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
493
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, old.path, details);
423
494
  const listed = capabilities.map((c) => c.name).sort(), locked = [...old.capabilities].sort();
424
495
  if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
425
496
  throw oatsError("E_PACKAGE_INTEGRITY",
426
497
  `packages.${id} ${version} @ ${obs.commit}: the lock lists capabilities [${locked.join(", ")}] but the package declares [${listed.join(", ")}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
427
498
  { ...details, version, why: "capabilities", listed, locked });
428
499
  }
429
- const { approved: _dropped, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
430
- nextPackages[id] = kept;
500
+ // The lock's souls must be what the package ships at that commit: the same names, paths and
501
+ // digests. A lock written before 0.28.0 carries no `souls` — it is filled in, not refused.
502
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
503
+ if (old.souls !== undefined) {
504
+ const show = (list) => list.map((x) => `${x.name}@${x.path}:${x.digest.slice(7, 19)}`).sort().join(", ");
505
+ if (show(old.souls) !== show(souls)) {
506
+ throw oatsError("E_PACKAGE_INTEGRITY",
507
+ `packages.${id} ${version} @ ${obs.commit}: the lock's souls [${show(old.souls)}] do not match what the package ships [${show(souls)}] — the lock was edited; remove this entry from oats-lock.json and run \`oats sync\` again`,
508
+ { ...details, version, why: "souls", listed: souls, locked: old.souls });
509
+ }
510
+ }
511
+ const { approved: _dropped, souls: _souls, ...kept } = clone(old); // a pre-0.26 approval record is dropped on write
512
+ nextPackages[id] = { ...kept, ...(souls.length ? { souls } : {}) };
431
513
  continue;
432
514
  }
433
515
 
434
- const { capabilities } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
516
+ const { capabilities, souls: soulDirs } = await readPackageManifests(remote, req.remoteRef, obs.commit, req.path, details);
435
517
  const integrity = assertDigest(`packages.${id} integrity`, await packageIntegrity(remote, req.remoteRef, obs.commit, req.path), details);
518
+ const souls = await packageSoulDigests(remote, req.remoteRef, obs.commit, soulDirs, details);
436
519
  nextPackages[id] = {
437
520
  source, url: obs.url, path: req.path, version, commit: obs.commit, integrity,
438
521
  capabilities: capabilities.map((c) => c.name).sort(),
522
+ ...(souls.length ? { souls } : {}),
439
523
  };
440
524
  changes.push({ id, from: old ? old.version : null, to: version, commit: obs.commit });
441
525
  }
package/lib/resolve.mjs CHANGED
@@ -472,6 +472,15 @@ function lookupPackage(lock, name, via, soul, declared) {
472
472
  return providing;
473
473
  }
474
474
 
475
+ /** `from: here` in a package soul: the capability must be one its OWN locked package provides. */
476
+ function ownPackageEntry(lock, name, via, soul, id) {
477
+ const where = { capability: name, from: "here", soul: soul.name, via, package: id };
478
+ const entry = isObject(lock?.packages) ? lock.packages[id] : null;
479
+ if (!isObject(entry)) throw fail("E_PACKAGE_MISSING", `${name}: from: here in package soul ${id}/${soul.name} needs ${id} in the lock — run \`oats sync\``, { ...where, reason: "no-lock" });
480
+ if (!entry.capabilities.includes(name)) throw fail("E_CAPABILITY_MISSING", `${name}: from: here in package soul ${id}/${soul.name}, but package ${id} v${entry.version} provides [${entry.capabilities.join(", ")}]`, { ...where, version: entry.version, listed: [...entry.capabilities] });
481
+ return { id, entry };
482
+ }
483
+
475
484
  /** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
476
485
  export function packageRef(id, entry, catalog, remote) {
477
486
  const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
@@ -582,8 +591,12 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
582
591
  const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
583
592
  for (const { name, from, via } of declared) {
584
593
  let module;
585
- if (from === "package") {
586
- const { id, entry } = lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
594
+ const ownPackage = from === "here" && typeof soulEntry.package === "string";
595
+ if (from === "package" || ownPackage) {
596
+ // `from: here` in a package soul is its own package, at the locked commit (§2.2).
597
+ const { id, entry } = ownPackage
598
+ ? ownPackageEntry(lock, name, via, soul, soulEntry.package)
599
+ : lookupPackage(lock, name, via, soul, discovery?.standalone === true ? null : (isObject(workspace?.packages) ? workspace.packages : {}));
587
600
  const ref = packageRef(id, entry, catalog, remote);
588
601
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
589
602
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
@@ -783,6 +796,11 @@ function assertSoulDiscovered(discovery, soulEntry) {
783
796
  const { name, repoKey } = soulEntry;
784
797
  const where = { soul: name, repoKey };
785
798
  const sameSoul = (s) => isObject(s) && s.name === name && s.repoKey === repoKey && (s.commit ?? null) === (soulEntry.commit ?? null);
799
+ if (typeof soulEntry.package === "string") {
800
+ // A package soul: the discovery listed it from the lock, at this package's locked commit.
801
+ if ((discovery?.packageSouls || []).some((s) => sameSoul(s) && s.package === soulEntry.package)) return;
802
+ throw fail("E_PACKAGE_MISSING", `soul ${soulEntry.package}/${name}: the discovery does not list it at ${short(soulEntry.commit)} — the soul entry is stale; run \`oats sync\` and discover again`, { ...where, package: soulEntry.package, reason: "stale" });
803
+ }
786
804
  if ((discovery?.external || []).some((e) => sameSoul(e?.soul))) return;
787
805
  const row = memberRow(discovery, repoKey);
788
806
  if (!row) throw fail("E_NOT_A_MEMBER", `soul ${name}: ${repoKey} is not a member of the workspace${discovery?.key ? ` ${discovery.key}` : ""} (nor an external soul) — a soul is spawned from a confirmed member or an external entry`, { ...where, reason: "not-listed" });
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];