@awebai/oats 0.28.0 → 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 (68) hide show
  1. package/bin/oats.mjs +296 -106
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
  3. package/capabilities/oats-okf/injects/okf.md +33 -33
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +2 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +1 -5
  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/okf-validate.mjs +123 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +28 -3
  11. package/capabilities/oats-okf/lib/stores.mjs +9 -4
  12. package/capabilities/oats-okf/lib/worker.mjs +82 -8
  13. package/capabilities/oats-okf/oats.json +14 -8
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
  16. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  17. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  18. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  19. package/capabilities/oats-okf-harvest/oats.json +26 -0
  20. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  21. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  22. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
  23. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  24. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  25. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  26. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  27. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  28. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  29. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  30. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  32. package/capabilities/oats-review/injects/review.md +3 -2
  33. package/capabilities/oats-review/oats.json +3 -4
  34. package/docs/capabilities.md +41 -9
  35. package/docs/capability-manifest.schema.json +0 -7
  36. package/docs/desktop-cli-api.md +257 -11
  37. package/docs/implementation.md +1 -1
  38. package/docs/knowledge-capability-authoring.md +8 -2
  39. package/docs/knowledge-reference/package-craft.md +8 -5
  40. package/docs/knowledge.md +101 -0
  41. package/docs/oats-local.schema.json +31 -1
  42. package/docs/official-catalog.md +7 -4
  43. package/docs/packages.md +11 -5
  44. package/docs/release-lane.md +1 -1
  45. package/docs/release-notes/v0.29.0.md +240 -0
  46. package/docs/schedules.md +133 -5
  47. package/docs/souls-and-instances.md +4 -6
  48. package/docs/workspaces.md +11 -2
  49. package/lib/automations.mjs +369 -0
  50. package/lib/core.mjs +65 -154
  51. package/lib/instance-inspect.mjs +12 -4
  52. package/lib/instance-resolution.mjs +31 -182
  53. package/lib/materialize.mjs +5 -7
  54. package/lib/operator-dispatch.mjs +1 -2
  55. package/lib/packages.mjs +17 -0
  56. package/lib/remote.mjs +21 -1
  57. package/lib/resolve.mjs +51 -7
  58. package/lib/schedule.mjs +211 -41
  59. package/lib/triggers.mjs +182 -49
  60. package/lib/workspace.mjs +1 -1
  61. package/package-catalog.json +6 -4
  62. package/package.json +1 -1
  63. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  64. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  65. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  66. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  67. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  68. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/docs/schedules.md CHANGED
@@ -39,7 +39,7 @@ see [Captured definitions](#captured-definitions-removed-in-026).
39
39
  ## Kinds
40
40
 
41
41
  - **spawn** `{id, enabled, cron, tz, kind: "spawn", agent, agentsRoot?,
42
- repo?, backend?, purpose?, task, harness?, model?, yolo?, wake?}` — every
42
+ repo?, backend?, purpose?, task, launchConfig?, harness?, model?, yolo?, wake?}` — every
43
43
  due minute launches one disposable instance of `agent` with the same
44
44
  options `oats spawn` takes. `agentsRoot` names the exact agents root that
45
45
  holds the soul (it must lie inside the workspace and defaults to the
@@ -153,7 +153,10 @@ logged in with the keyring or its config file under your HOME works there. A
153
153
  request's title and body are untrusted and never reach the task (a template
154
154
  naming any other field is refused). `teams` becomes the messaging
155
155
  capability's `join=` setting (as `--provider <messaging cap> join=<labels>`).
156
- `harness`, `model`, `yolo`, `backend` are as for schedules.
156
+ `launchConfig` (a launch configuration in the running host's
157
+ `oats-local.yaml` `launch-configs`), `harness`, `model`, `yolo`, `backend`
158
+ are as for schedules; a package template may expose any of them as a
159
+ parameter (`"path": "spawn.launchConfig"`).
157
160
  - **The event reaches the instance** as `OATS_TRIGGER_EVENT_FILE`
158
161
  (`<home>/.oats/trigger-event.json`: `{ trigger, source, repo, number, url,
159
162
  event, headSha, labels, observedAt, key }`), given to the spawn hooks and the
@@ -169,7 +172,7 @@ oats trigger add --from oats.okf:harvest-review --set repo=github.com/acme/knowl
169
172
  oats trigger list | show <id> | enable <id> | disable <id> | remove <id>
170
173
  oats trigger test <id> # dry run: gh auth + credential source, repo + permissions (push/maintain/admin), the soul resolves,
171
174
  # its messaging capability, the teams declared, what WOULD fire now; spawns nothing
172
- oats trigger status [<id>] # last poll, pending, fired keys, live instances, last error
175
+ oats trigger status [<id>] # last poll, next due, pending, fired keys (time, instance), live vs max, last error
173
176
  ```
174
177
 
175
178
  All take `--dir` and `--json` (`triggerApi: 1`). `remove` leaves the instances
@@ -187,6 +190,119 @@ comma-separated); a required parameter without a value is `E_BAD_ARGS
187
190
  { missing }` naming it. The trigger records `template: { package, version,
188
191
  commit, template }`.
189
192
 
193
+ ## Workspace triggers and schedules
194
+
195
+ (OATS 0.29.0, feature `automations`.) A trigger or a schedule is defined at one of
196
+ two levels:
197
+
198
+ - **in the workspace**: a file committed in a confirmed member repository, shared
199
+ through Git and named `<member>/<id>`. This is the default for anything a team
200
+ relies on.
201
+ - **locally**: in the deployment's `oats-schedules.json` (`oats trigger add`,
202
+ `oats schedule add`), machine-private and named `local/<id>`.
203
+
204
+ The two kinds stay separate at every step. Each has its own folder, its own file
205
+ kind, its own ids, its own commands, its own list and its own opt-out.
206
+
207
+ | | trigger | schedule |
208
+ | --- | --- | --- |
209
+ | canonical folder (at the member's root) | `oats-triggers/` | `oats-schedules/` |
210
+ | file name anywhere in the member | `*.oats-trigger.yaml` | `*.oats-schedule.yaml` |
211
+ | `kind:` | `oats-trigger` | `oats-schedule` |
212
+ | body | `from:` + `set:` (a package template), or `on`, `spawn`, `concurrency` as above | `run: spawn \| command`, `cron`, `tz`, `agent`, `task`, `purpose`, `launchConfig`, `harness`, `model`, `yolo`, `backend`, `wake`, `argv`, `cwd` |
213
+ | commands | `oats trigger …` | `oats schedule …` |
214
+ | opt-out on this host | `triggers.disabled` | `schedules.disabled` |
215
+
216
+ Every `.yaml`/`.yml` under a canonical folder is a candidate, and so is a file with
217
+ the kind's suffix anywhere in the member (`.yml` works too), e.g.
218
+ `services/billing/nightly.oats-schedule.yaml` beside the code it concerns.
219
+ `oats-package/`, `.git/` and `node_modules/` are never scanned.
220
+
221
+ ```yaml
222
+ # <member>/oats-triggers/okf-harvest-review.yaml
223
+ kind: oats-trigger
224
+ schemaVersion: 1
225
+ description: Review every harvest PR on the knowledge base
226
+ from: oats.okf:harvest-review # a package template at the locked commit, then its parameters
227
+ set: { repo: github.com/acme/knowledge }
228
+ runsOn: kb-bot-server # the host.name that runs it
229
+ owner: github.com/acme-kb-bot # the GitHub account it acts as
230
+ ```
231
+
232
+ ```yaml
233
+ # <member>/services/billing/nightly.oats-schedule.yaml
234
+ kind: oats-schedule
235
+ schemaVersion: 1
236
+ run: spawn
237
+ cron: "0 7 * * *"
238
+ tz: Europe/Madrid
239
+ agent: digest-writer # resolved like `oats spawn <soul>` (member or package soul)
240
+ task: Write the nightly digest.
241
+ runsOn: ana-laptop
242
+ owner: github.com/ana
243
+ ```
244
+
245
+ - **The file describes itself.** It carries `kind` and `schemaVersion: 1`. A
246
+ candidate of the wrong kind (a schedule in `oats-triggers/`) or without one is an
247
+ `E_AUTOMATION_SCHEMA` problem, never silently skipped.
248
+ - **The id** is `id:`, else the filename stem. The same id twice in one member for
249
+ one kind is `E_AUTOMATION_DUPLICATE`, naming both paths; the second file is not
250
+ listed. A trigger and a schedule may share an id: they are different things.
251
+ - **A workspace schedule is `run: spawn` or `run: command`.** A `command`'s `cwd` is
252
+ relative to the deployment. `wake` and `operation` target an instance home on one
253
+ machine, so they stay local.
254
+
255
+ **Who runs it.** A host runs a workspace trigger or schedule only when both of these
256
+ hold:
257
+
258
+ 1. its `runsOn` is this host's `host.name` in `oats-local.yaml`;
259
+ 2. the host's authenticated `gh` account (`gh api user`, asked once per tick) is its
260
+ `owner`.
261
+
262
+ Otherwise the item is listed with a reason:
263
+
264
+ - `assigned-elsewhere`: another host runs it;
265
+ - `owner-mismatch`: this host is named, but its `gh` is logged in as someone else or
266
+ not at all. Nothing runs, the tick reports it, and `oats trigger test` says so;
267
+ - `host-unnamed`: this host has no `host.name`.
268
+
269
+ So exactly one machine runs it, and consent is explicit: the machine's operator
270
+ named the host and is logged in as the account.
271
+
272
+ **Opting out** without a commit: `oats trigger disable <member>/<id>` writes
273
+ `triggers.disabled`, and `oats schedule disable <member>/<id>` writes
274
+ `schedules.disabled`, in `oats-local.yaml`. `enable` removes the entry. A
275
+ workspace definition is never edited or removed here (`update` and `remove` are
276
+ `E_AUTOMATION_WORKSPACE`): change the file in Git.
277
+
278
+ **Refresh.**
279
+
280
+ - `oats sync` discovers the workspace triggers and schedules of the confirmed
281
+ members into a snapshot, `.agents/automations/snapshot.json` in the deployment,
282
+ with one list per kind. It takes one tree listing per member commit.
283
+ - The host tick reads that snapshot. It refreshes it (`oats automations refresh`)
284
+ when the snapshot is more than ten minutes old, at most once per interval. When
285
+ the refresh fails, the last good snapshot keeps serving.
286
+ - A change in Git therefore reaches the named host within about ten minutes.
287
+ - The run state (dedup keys, last poll, last run) stays per host and local. A
288
+ workspace schedule's job lock and state are keyed `<member>~<id>`.
289
+ - A trigger template (`from:`) is instantiated when the snapshot is taken, at the
290
+ commit the host's lock pins.
291
+
292
+ **Writing one.** Add `--workspace <member> --runs-on <host> --owner <host>/<login>`
293
+ to `oats trigger add` or `oats schedule add`:
294
+
295
+ - Run inside a checkout of that member, it writes `oats-triggers/<id>.yaml` or
296
+ `oats-schedules/<id>.yaml` there, for you to commit and push.
297
+ - Anywhere else, it prints the file.
298
+ - Either way, the file is read back and validated first.
299
+ - `oats trigger test <member>/<id>` checks the placement, and everything else it
300
+ checked before, on this host.
301
+
302
+ Everything above still holds: the soul must resolve here, templates name only the
303
+ whitelisted fields, a PR's text is never interpolated, and no definition carries a
304
+ credential.
305
+
190
306
  ## Captured definitions (removed in 0.26)
191
307
 
192
308
  0.24–0.25 could save captured command definitions: `definitionVersion`,
@@ -217,6 +333,7 @@ oats schedule add <id> --file spec.json --dir <workspace> --json
217
333
  oats schedule update <id> --file spec.json
218
334
  oats schedule list | show <id> | enable <id> | disable <id> | remove <id>
219
335
  oats schedule run <id> # now, under the same lock and bound
336
+ oats schedule test <id> # dry run: where it runs, whether its soul resolves, when it is next due; spawns nothing
220
337
  oats schedule tick --dry-run # what would run this minute, launching nothing
221
338
  oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
222
339
  oats schedule host install # register this scope and install the ONE host timer (idempotent while active)
@@ -227,8 +344,9 @@ oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --w
227
344
  Every subcommand takes `--server <id>` instead of `--dir`: it then runs on
228
345
  that host, in its registered workspace, because schedules are host-owned.
229
346
 
230
- `list --json` answers `{schedules: [{id, ...definition, nextRun, lastRun,
231
- running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}`.
347
+ `list --json` answers `{schedules: [{id, ...definition, nextDue, lastRun,
348
+ running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}`
349
+ (`oats trigger list --json` carries the same `scheduler`).
232
350
  `active` is what the OS reports about the timer, not whether a file exists.
233
351
 
234
352
  ## What a run reports
@@ -292,6 +410,16 @@ the instance is neither hidden nor spawned again.
292
410
 
293
411
  ## OKF v2 source jobs
294
412
 
413
+ > **oats.okf 4.0.0:** a source and its job exist only where harvest is
414
+ > effectively on (`harvest: on|off`, default off), and the job spawns the
415
+ > harvester package soul. The review of its PR runs as a **trigger**, run by
416
+ > the same host tick: a workspace file (`oats-triggers/okf-harvest-review.yaml`
417
+ > in a member repo, `kind: oats-trigger`, with `runsOn` and `owner`), or a
418
+ > local one in this file. See
419
+ > [knowledge.md](knowledge.md#knowledge-operations), [Triggers](#triggers) and
420
+ > [Workspace triggers and schedules](#workspace-triggers-and-schedules); the
421
+ > trigger commands are `oats trigger …`.
422
+
295
423
  The [prepared OKF v2 runtime](knowledge.md) registers **one command job per
296
424
  source**, not a fleet sweep or a home-bound operation job. It runs from stable
297
425
  deployment context with argv equivalent to:
@@ -516,14 +516,12 @@ Default layout:
516
516
  docs-expert/ # a workspace soul, defined in a member repository's
517
517
  souls/<commit12>/ # souls/docs-expert/ and copied here per commit
518
518
  instances/
519
- memory-harvest/ # a capability-defined agent: only instances/, no soul
520
- instances/
521
519
  ```
522
520
 
523
- A capability-defined agent (declared by a package or member module, such as
524
- the OKF harvester) homes under the agents root exactly like a soul; its
525
- directory holds only `instances/`. A name that is both a workspace soul and a
526
- capability agent is ambiguous (`E_SOUL_AMBIGUOUS`).
521
+ Every agent is a soul — a member soul, or a package soul homed under
522
+ `agents/<package>--<soul>/`. A capability-defined agent (a manifest's
523
+ `agents:`) was removed in 0.29.0; a home an earlier kernel left for one (its
524
+ directory holds only `instances/`) is still listed and retirable.
527
525
 
528
526
  There are no local souls. A soul is a member repository's `souls/<name>`
529
527
  (`soul.yaml` + `AGENTS.md`); author it there and run `oats sync`. OATS 0.25
@@ -59,8 +59,8 @@ members: # repo refs, NO @revision (E_WORKSPAC
59
59
  - git:github.com/acme/tools # a member that ALSO publishes a package (see below)
60
60
 
61
61
  packages: # the ONLY versioned things
62
- oats.framework: v1.1.3 # bare version → resolves through the official catalog
63
- oats.okf: v3.0.0
62
+ oats.framework: v1.3.0 # bare version → resolves through the official catalog
63
+ oats.okf: v4.0.0
64
64
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
65
65
 
66
66
  teams: # labels, declared once so they cannot drift
@@ -169,8 +169,17 @@ settings: # host-owned values the manifests ask
169
169
  state-dir: /Users/ana/.oats/okf
170
170
  souls:
171
171
  disabled: [data-analyst] # not run on this machine (E_SOUL_DISABLED); oats.okf/knowledge-harvester names a package soul
172
+ host:
173
+ name: ana-laptop # this machine's name: runs the workspace triggers/schedules whose runsOn names it
174
+ triggers:
175
+ disabled: [knowledge/okf-harvest-review] # workspace triggers this host does not run (oats trigger disable)
176
+ schedules:
177
+ disabled: [platform/nightly-digest] # workspace schedules this host does not run (oats schedule disable)
172
178
  ```
173
179
 
180
+ `host`, `triggers.disabled` and `schedules.disabled` (0.29.0) are machine facts:
181
+ see [schedules.md#workspace-triggers-and-schedules](schedules.md#workspace-triggers-and-schedules).
182
+
174
183
  See [configuration.md](configuration.md). `oats-config.yaml` no longer exists.
175
184
 
176
185
  ### `oats-lock.json` — lock v3
@@ -0,0 +1,369 @@
1
+ /** What workspace triggers and workspace schedules share (kernel 0.29.0; design
2
+ * docs/design/2026-09-26-okf-knowledge-operations.md §2.3a). Nothing here knows what a trigger
3
+ * or a schedule IS: each kind's module (lib/triggers.mjs, lib/schedule.mjs) owns its file body,
4
+ * its validation and its rows, and hands this module a KIND DESCRIPTOR:
5
+ *
6
+ * { kind: "trigger" | "schedule", folder, suffix, fileKind, bodyKeys,
7
+ * parseBody(body) → source | throws { message, field },
8
+ * expand(entry) → definition | throws (may be async) }
9
+ *
10
+ * Triggers and schedules are defined at one of two levels:
11
+ * - the WORKSPACE level: a file committed in a confirmed member repository, shared through Git,
12
+ * named `<member>/<id>`;
13
+ * - LOCALLY, in the deployment's oats-schedules.json (`oats trigger add`, `oats schedule add`):
14
+ * machine-private, named `local/<id>`.
15
+ *
16
+ * Shared rules:
17
+ * - WHERE: a kind's canonical folder at the member's root (every *.yaml / *.yml under it) or a
18
+ * file named `*.<suffix>.yaml` (.yml too) anywhere in the member; never under `oats-package/`,
19
+ * `.git/` or `node_modules/`. One recursive tree listing per member commit serves both kinds.
20
+ * - THE HEADER: `kind: <fileKind>` + `schemaVersion: 1` (a wrong or missing kind is
21
+ * E_AUTOMATION_SCHEMA), `id:` or the filename stem, `runsOn` (a host name), `owner` (a GitHub
22
+ * account, `<host>/<login>`), `description?`, `enabled?`. A duplicate id within one member and
23
+ * one kind is E_AUTOMATION_DUPLICATE.
24
+ * - PLACEMENT: a host runs one ONLY when `runsOn` is its `host.name` (oats-local.yaml) AND its
25
+ * authenticated `gh` account is `owner`; otherwise it is listed with the reason
26
+ * (`host-unnamed`, `assigned-elsewhere`, `owner-mismatch`). Each kind's own list in
27
+ * oats-local.yaml (`triggers.disabled`, `schedules.disabled`) stops a named host from running
28
+ * one.
29
+ * - THE SNAPSHOT: discovery reads the remotes (async), the host tick is synchronous; so
30
+ * discovery writes <deployment>/.agents/automations/snapshot.json (`oats sync`,
31
+ * `oats automations refresh`, which the tick runs as a child when the snapshot is ten minutes
32
+ * old), one list per kind, and the tick reads it. The run state stays local. */
33
+ import { spawnSync } from "node:child_process";
34
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
35
+ import { dirname, join } from "node:path";
36
+ import YAML from "yaml";
37
+ import { parseConfigData } from "./config-data.mjs";
38
+
39
+ export const AUTOMATIONS_API = 1;
40
+ export const SNAPSHOT_MAX_AGE_MS = 10 * 60_000;
41
+ export const PLACEMENT_REASONS = Object.freeze(["host-unnamed", "assigned-elsewhere", "owner-mismatch"]);
42
+ /** The kinds, in the order the snapshot keeps them (`triggers`, `schedules`). */
43
+ export const KIND_NAMES = Object.freeze(["trigger", "schedule"]);
44
+ const NEVER_SCANNED = new Set(["oats-package", ".git", "node_modules"]);
45
+ export const AUTOMATION_ID_RE = /^[a-z0-9-]{1,40}$/;
46
+ export const HOST_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
47
+ const OWNER_RE = /^([a-z0-9-]+(?:\.[a-z0-9-]+)+)\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/;
48
+ const HEADER_KEYS = ["kind", "schemaVersion", "id", "description", "runsOn", "owner", "enabled"];
49
+ const LOCAL = "local";
50
+
51
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
52
+ export function automationError(code, message, details) { return Object.assign(new Error(message), { code, ...(details ? { details } : {}) }); }
53
+
54
+ // ------------------------------------------------------------ names
55
+
56
+ /** `local/<id>` for a machine-private trigger or schedule, `<member>/<id>` for a workspace one. */
57
+ export const localId = (id) => `${LOCAL}/${id}`;
58
+ /** Split a qualified id; a bare id is a local one. → { scope: "local" | <member>, name } */
59
+ export function splitId(text) {
60
+ const s = String(text ?? "");
61
+ const i = s.indexOf("/");
62
+ return i < 0 ? { scope: LOCAL, name: s } : { scope: s.slice(0, i), name: s.slice(i + 1) };
63
+ }
64
+ /** The qualified form of an id argument (a bare id is a local one). */
65
+ export const qualifiedId = (text) => { const { scope, name } = splitId(text); return `${scope}/${name}`; };
66
+ /** `<host>/<login>` → { host, login } | null (a login compares case-insensitively). */
67
+ export function parseOwner(text) {
68
+ const m = typeof text === "string" ? OWNER_RE.exec(text.trim()) : null;
69
+ return m ? { host: m[1], login: m[2] } : null;
70
+ }
71
+
72
+ // ------------------------------------------------------------ discovery
73
+
74
+ /** Which kind (of the given descriptors) a tree path would be. → { kind, stem } | null */
75
+ export function candidateOf(path, kinds) {
76
+ const segs = String(path).split("/");
77
+ if (segs.some((s) => NEVER_SCANNED.has(s))) return null;
78
+ const base = segs.at(-1);
79
+ for (const k of kinds) {
80
+ const suffix = `.${k.suffix}.`;
81
+ const at = base.lastIndexOf(suffix);
82
+ if (at > 0 && /^ya?ml$/.test(base.slice(at + suffix.length))) return { kind: k.kind, stem: base.slice(0, at) };
83
+ }
84
+ for (const k of kinds) if (segs[0] === k.folder && segs.length > 1 && /\.ya?ml$/.test(base)) return { kind: k.kind, stem: base.replace(/\.ya?ml$/, "") };
85
+ return null;
86
+ }
87
+
88
+ /** Parse one candidate file: the shared header here, the body by the kind's descriptor. The
89
+ * definition is not expanded yet. Never throws: → { entry } | { problem } */
90
+ export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey, commit }) {
91
+ const origin = { kind: "workspace", repoKey, path, commit };
92
+ const problem = (message, field) => ({ problem: { code: "E_AUTOMATION_SCHEMA", kind: desc.kind, repoKey, path: field ? `${path}#/${field}` : path, message } });
93
+ let doc;
94
+ try { doc = parseConfigData(bytes, { origin: { kind: desc.fileKind, repoKey, commit, path } }).value; }
95
+ catch (e) { return problem(`cannot decode: ${e.message}`); }
96
+ if (!isObject(doc)) return problem(`a ${desc.fileKind} file is a mapping with kind: ${desc.fileKind} and schemaVersion: 1`);
97
+ if (doc.kind !== desc.fileKind) return problem(`kind must be ${JSON.stringify(desc.fileKind)} (found ${JSON.stringify(doc.kind ?? null)}): a file ${path.split("/").at(-1).includes(`.${desc.suffix}.`) ? `named *.${desc.suffix}.yaml` : `in ${desc.folder}/`} follows the ${desc.fileKind} contract`, "kind");
98
+ if (doc.schemaVersion !== 1) return problem(`schemaVersion must be 1 (found ${JSON.stringify(doc.schemaVersion ?? null)})`, "schemaVersion");
99
+ const allowed = [...HEADER_KEYS, ...desc.bodyKeys];
100
+ const unknown = Object.keys(doc).filter((k) => !allowed.includes(k));
101
+ if (unknown.length) return problem(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (a ${desc.fileKind} carries ${allowed.join(", ")})`, unknown[0]);
102
+ const name = doc.id === undefined ? stem : doc.id;
103
+ if (typeof name !== "string" || !AUTOMATION_ID_RE.test(name)) return problem(`the id (${doc.id === undefined ? "the filename stem" : "id:"} ${JSON.stringify(name)}) must be lowercase letters, digits and dashes, 1 to 40 characters`, "id");
104
+ if (typeof doc.runsOn !== "string" || !HOST_NAME_RE.test(doc.runsOn)) return problem("runsOn: the host name that runs it (oats-local.yaml host.name: lowercase letters, digits and dashes)", "runsOn");
105
+ const owner = parseOwner(doc.owner);
106
+ if (!owner) return problem("owner: the GitHub account it acts as, <host>/<login> (e.g. github.com/acme-kb-bot)", "owner");
107
+ if (doc.description !== undefined && typeof doc.description !== "string") return problem("description: text", "description");
108
+ if (doc.enabled !== undefined && typeof doc.enabled !== "boolean") return problem("enabled: boolean", "enabled");
109
+ let source;
110
+ try { source = desc.parseBody(Object.fromEntries(Object.entries(doc).filter(([k]) => !HEADER_KEYS.includes(k)))); }
111
+ catch (e) { return problem(e.message, e.field); }
112
+ return { entry: { kind: desc.kind, id: `${member}/${name}`, name, member, description: doc.description ?? null, runsOn: doc.runsOn, owner: `${owner.host}/${owner.login}`, enabled: doc.enabled !== false, origin, source } };
113
+ }
114
+
115
+ /** Discover the workspace triggers and schedules of the confirmed members: one tree listing per
116
+ * member commit serves every kind (a member whose commit did not change reuses the previous
117
+ * snapshot's listing). Each kind's descriptor parses its body and expands it into a validated
118
+ * kernel definition; a failure lists the entry as invalid and is reported.
119
+ * → { byKind: { trigger: [...], schedule: [...] }, problems, members } */
120
+ export async function discoverAutomations(discovery, { remote, memberName, kinds, previous = null } = {}) {
121
+ const byKind = Object.fromEntries(kinds.map((k) => [k.kind, []]));
122
+ const problems = [], members = {};
123
+ const names = new Map();
124
+ for (const m of (discovery?.members || []).filter((x) => x.confirmed && x.commit)) {
125
+ const name = memberName(m.key);
126
+ if (names.has(name)) { problems.push({ code: "E_AUTOMATION_DUPLICATE", repoKey: m.key, path: "", message: `member name ${JSON.stringify(name)} is also ${names.get(name)}'s: triggers and schedules are named <member>/<id>, so ${m.key}'s are not listed` }); continue; }
127
+ names.set(name, m.key);
128
+ let candidates;
129
+ const cached = previous?.members?.[m.key];
130
+ if (cached && cached.commit === m.commit && Array.isArray(cached.candidates)) candidates = cached.candidates;
131
+ else {
132
+ let tree;
133
+ try { tree = await remote.listRemoteTree(m.ref ?? m.key, m.commit, "", { depth: 64 }); }
134
+ catch (e) { problems.push({ code: e?.code || "E_REMOTE_UNREADABLE", repoKey: m.key, path: "", message: `triggers and schedules cannot be listed: ${e.message}` }); continue; }
135
+ candidates = tree.filter((e) => e.type === "blob" && candidateOf(e.path, kinds)).map((e) => e.path);
136
+ }
137
+ members[m.key] = { name, commit: m.commit, candidates };
138
+ const seen = Object.fromEntries(kinds.map((k) => [k.kind, new Map()]));
139
+ for (const path of [...candidates].sort()) {
140
+ const c = candidateOf(path, kinds);
141
+ if (!c) continue;
142
+ const desc = kinds.find((k) => k.kind === c.kind);
143
+ let bytes;
144
+ try { ({ bytes } = await remote.readRemoteFile(m.ref ?? m.key, m.commit, path)); }
145
+ catch (e) { problems.push({ code: e?.code || "E_REMOTE_UNREADABLE", kind: c.kind, repoKey: m.key, path, message: e.message }); continue; }
146
+ const parsed = parseAutomationFile(desc, { stem: c.stem, path, bytes, member: name, repoKey: m.key, commit: m.commit });
147
+ if (parsed.problem) { problems.push(parsed.problem); continue; }
148
+ const a = parsed.entry;
149
+ const first = seen[a.kind].get(a.name);
150
+ if (first) { problems.push({ code: "E_AUTOMATION_DUPLICATE", kind: a.kind, repoKey: m.key, path, message: `${a.kind} id ${JSON.stringify(a.name)} is declared by both ${first} and ${path}; ${path} is not listed` }); continue; }
151
+ seen[a.kind].set(a.name, path);
152
+ try { a.definition = await desc.expand(a); }
153
+ catch (e) {
154
+ a.definition = null;
155
+ a.invalid = { code: e.code || "E_AUTOMATION_SCHEMA", message: e.message, ...(e.field ? { field: e.field } : e.details?.field ? { field: e.details.field } : {}) };
156
+ problems.push({ code: a.invalid.code, kind: a.kind, repoKey: m.key, path, message: `${a.kind} ${a.id}: ${e.message}` });
157
+ }
158
+ byKind[a.kind].push(a);
159
+ }
160
+ }
161
+ for (const list of Object.values(byKind)) list.sort((x, y) => x.id.localeCompare(y.id));
162
+ return { byKind, problems, members };
163
+ }
164
+
165
+ /** The soul index a snapshot keeps, so list rows can say where a soul comes from. */
166
+ export function soulIndexOf(discovery, memberName) {
167
+ const byName = {}, byQualified = {};
168
+ const add = (name, qualified, origin) => { (byName[name] ||= []).push(origin); byQualified[qualified] = origin; };
169
+ for (const m of discovery?.members || []) for (const s of m.souls || []) add(s.name, `${memberName(m.key)}/${s.name}`, { kind: "member", repoKey: m.key, member: memberName(m.key) });
170
+ for (const s of discovery?.packageSouls || []) add(s.name, s.qualifiedName, { kind: "package", package: s.package, version: s.version });
171
+ for (const e of discovery?.external || []) add(e.soul.name, `${memberName(e.key)}/${e.soul.name}`, { kind: "external", repoKey: e.key, source: e.source });
172
+ return { byName, byQualified };
173
+ }
174
+ /** Where a soul named by a trigger or schedule comes from, per the snapshot. → origin | null */
175
+ export function soulOriginOf(index, soul) {
176
+ if (!index || typeof soul !== "string") return null;
177
+ if (soul.includes("/")) return index.byQualified?.[soul] ?? null;
178
+ const hits = index.byName?.[soul] ?? [];
179
+ if (hits.length === 1) return hits[0];
180
+ return hits.length ? { kind: "ambiguous", candidates: hits.length } : null;
181
+ }
182
+
183
+ // ------------------------------------------------------------ snapshot
184
+
185
+ export const snapshotPath = (dep) => join(dep, ".agents", "automations", "snapshot.json");
186
+ export function readSnapshot(dep) {
187
+ try {
188
+ const doc = JSON.parse(readFileSync(snapshotPath(dep), "utf8"));
189
+ return isObject(doc) && KIND_NAMES.every((k) => Array.isArray(doc[`${k}s`])) ? doc : null;
190
+ } catch { return null; }
191
+ }
192
+ export function writeSnapshot(dep, snap) {
193
+ const file = snapshotPath(dep);
194
+ mkdirSync(dirname(file), { recursive: true });
195
+ const tmp = `${file}.tmp-${process.pid}`;
196
+ writeFileSync(tmp, JSON.stringify(snap, null, 2) + "\n");
197
+ renameSync(tmp, file);
198
+ return file;
199
+ }
200
+ /** The snapshot document of one discovery: one list per kind (`triggers`, `schedules`). */
201
+ export function snapshotOf(discovery, found, { souls = null, now = new Date() } = {}) {
202
+ return {
203
+ automationsApi: AUTOMATIONS_API, takenAt: now.toISOString(),
204
+ workspace: discovery?.standalone === true ? null : { key: discovery?.key ?? null, commit: discovery?.commit ?? null },
205
+ members: found.members,
206
+ ...Object.fromEntries(KIND_NAMES.map((k) => [`${k}s`, found.byKind[k] ?? []])),
207
+ problems: found.problems, souls,
208
+ };
209
+ }
210
+ const attemptPath = (dep) => join(dirname(snapshotPath(dep)), "last-refresh-attempt");
211
+ function sinceAttempt(dep, now) { try { return now.getTime() - Date.parse(readFileSync(attemptPath(dep), "utf8").trim()); } catch { return Infinity; } }
212
+ const snapshotAge = (snap, now) => (snap?.takenAt ? now.getTime() - Date.parse(snap.takenAt) : Infinity);
213
+
214
+ // ------------------------------------------------------------ this host
215
+
216
+ /** The host's own facts from oats-local.yaml (never in Git): its name and, per kind, the
217
+ * workspace ids it does not run (`triggers.disabled`, `schedules.disabled`). */
218
+ export function hostOf(local) {
219
+ const name = isObject(local?.host) && typeof local.host.name === "string" ? local.host.name : null;
220
+ const disabled = Object.fromEntries(KIND_NAMES.map((k) => [k, new Set(Array.isArray(local?.[`${k}s`]?.disabled) ? local[`${k}s`].disabled : [])]));
221
+ return { name, disabled };
222
+ }
223
+ /** The account the host's `gh` is logged in as on one GitHub host (`gh api user`), memoized in
224
+ * `cache` (one per tick). `io.gh(args)` is the seam. → { ok, login } | { ok: false, error } */
225
+ export function ghLogin(ghHost, { io, cache } = {}) {
226
+ if (cache?.has(ghHost)) return cache.get(ghHost);
227
+ const argv = ["api", "user", "--jq", ".login", ...(ghHost === "github.com" ? [] : ["--hostname", ghHost])];
228
+ let r;
229
+ if (io?.gh) r = io.gh(argv);
230
+ else {
231
+ const p = spawnSync("gh", argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, env: { ...process.env, GH_PROMPT_DISABLED: "1" } });
232
+ r = p.error ? { status: 127, stdout: "", stderr: p.error.code === "ENOENT" ? "gh is not installed on this host" : p.error.message } : p;
233
+ }
234
+ const login = String(r.stdout || "").trim();
235
+ const out = r.status === 0 && login ? { ok: true, login } : { ok: false, error: String(r.stderr || r.stdout).split("\n").map((l) => l.trim()).find(Boolean) || `gh exited ${r.status}` };
236
+ cache?.set(ghHost, out);
237
+ return out;
238
+ }
239
+ /** Where a workspace trigger or schedule runs, from this host's point of view.
240
+ * → { runsHere, reason: null | host-unnamed | assigned-elsewhere | owner-mismatch, detail?, enabledHere } */
241
+ export function placementOf(a, { host, io, cache }) {
242
+ const enabledHere = a.enabled !== false && !host.disabled[a.kind]?.has(a.id);
243
+ let reason = null, detail;
244
+ if (!host.name) { reason = "host-unnamed"; detail = "this host has no host.name in oats-local.yaml"; }
245
+ else if (a.runsOn !== host.name) { reason = "assigned-elsewhere"; detail = `runs on ${a.runsOn}; this host is ${host.name}`; }
246
+ else {
247
+ const owner = parseOwner(a.owner);
248
+ const who = owner ? ghLogin(owner.host, { io, cache }) : { ok: false, error: "owner is not <host>/<login>" };
249
+ if (!who.ok) { reason = "owner-mismatch"; detail = `acts as ${a.owner}, but this host's gh is not logged in on ${owner?.host ?? "?"}: ${who.error}`; }
250
+ else if (who.login.toLowerCase() !== owner.login.toLowerCase()) { reason = "owner-mismatch"; detail = `acts as ${a.owner}; this host's gh is logged in as ${owner.host}/${who.login}`; }
251
+ }
252
+ return { runsHere: reason === null && enabledHere && !a.invalid && !!a.definition, reason, ...(detail ? { detail } : {}), enabledHere };
253
+ }
254
+ /** A local trigger or schedule as a context entry: this host, implicitly; no runsOn/owner. */
255
+ export function localEntry(kind, def, { invalid = null, dep = null } = {}) {
256
+ const enabled = def.enabled !== false;
257
+ return { kind, id: localId(def.id), name: def.id, member: LOCAL, description: null, runsOn: null, owner: null, enabled, origin: { kind: "local", path: "oats-schedules.json", url: null, localPath: dep ? join(dep, "oats-schedules.json") : null }, definition: def, ...(invalid ? { invalid } : {}), placement: { runsHere: enabled && !invalid, reason: null, enabledHere: enabled } };
258
+ }
259
+
260
+ /** One deployment's context for a tick or a listing: the snapshot (refreshed first when `refresh`
261
+ * and it is ten minutes old — at most one attempt per interval) and each workspace trigger and
262
+ * schedule placed on this host, kept per kind.
263
+ * → { snapshot, host, triggers: [entry + placement], schedules: [...], refresh: null | { ok, error? } } */
264
+ export function automationContext(dep, { local, io, now = new Date(), refresh = false, cache = new Map(), clonePathOf = null } = {}) {
265
+ let snapshot = readSnapshot(dep);
266
+ let refreshed = null;
267
+ const realizesWorkspace = typeof local?.workspace === "string" && local?.standalone === undefined;
268
+ if (refresh && realizesWorkspace && snapshotAge(snapshot, now) >= SNAPSHOT_MAX_AGE_MS && sinceAttempt(dep, now) >= SNAPSHOT_MAX_AGE_MS) {
269
+ // One attempt per interval, whether it works or not: a remote that is down is not asked
270
+ // again every minute (the last good snapshot keeps serving).
271
+ try { mkdirSync(dirname(snapshotPath(dep)), { recursive: true }); writeFileSync(attemptPath(dep), now.toISOString() + "\n"); } catch { /* the refresh itself reports */ }
272
+ refreshed = refreshViaCli(dep, io);
273
+ if (refreshed.ok) snapshot = readSnapshot(dep);
274
+ }
275
+ const host = hostOf(local);
276
+ const place = (list) => (list || []).map((a) => ({ ...a, origin: { ...a.origin, ...originLinks(a.origin, clonePathOf) }, placement: placementOf(a, { host, io, cache }) }));
277
+ const ctx = { snapshot, host, ...Object.fromEntries(KIND_NAMES.map((k) => [`${k}s`, place(snapshot?.[`${k}s`])])), refresh: refreshed };
278
+ /** The login this host's gh is authenticated as on each GitHub host the listed items (and
279
+ * `extraHosts`) name: { <host>: <login> | null }. Asked once per host (the tick's cache). */
280
+ ctx.ghUsers = (extraHosts = []) => {
281
+ const hosts = new Set(extraHosts);
282
+ for (const k of KIND_NAMES) for (const a of ctx[`${k}s`]) { const o = parseOwner(a.owner); if (o) hosts.add(o.host); }
283
+ return Object.fromEntries([...hosts].sort().map((h) => { const who = ghLogin(h, { io, cache }); return [h, who.ok ? who.login : null]; }));
284
+ };
285
+ return ctx;
286
+ }
287
+ /** Where a workspace file can be opened: its web URL at the commit (GitHub-style, for a
288
+ * github.com repository; null otherwise) and its path in this machine's clone of the member
289
+ * (null when the member is not cloned here). → { url, localPath } */
290
+ export function originLinks(origin, clonePathOf) {
291
+ if (origin?.kind !== "workspace") return {};
292
+ const m = /^github\.com\/([^/]+)\/([^/]+)$/.exec(String(origin.repoKey));
293
+ const url = m && origin.commit ? `https://github.com/${m[1]}/${m[2].replace(/\.git$/, "")}/blob/${origin.commit}/${origin.path}` : null;
294
+ let clone = null;
295
+ try { clone = clonePathOf ? clonePathOf(origin.repoKey) : null; } catch { clone = null; }
296
+ return { url, localPath: clone ? join(clone, origin.path) : null };
297
+ }
298
+ /** `oats automations refresh --json` as a child (the tick is synchronous; discovery is not). */
299
+ function refreshViaCli(dep, io) {
300
+ if (io?.refresh) return io.refresh(dep);
301
+ const bin = io?.oatsBin || new URL("../bin/oats.mjs", import.meta.url).pathname;
302
+ const r = spawnSync(process.execPath, [bin, "automations", "refresh", "--dir", dep, "--json"], { cwd: dep, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.refreshTimeoutMs || 180_000, env: io?.childEnv ? io.childEnv() : process.env });
303
+ let env = null;
304
+ try { env = JSON.parse(String(r.stdout || "").trim().split("\n").pop()); } catch { env = null; }
305
+ if (env?.ok === true) return { ok: true };
306
+ return { ok: false, error: env?.error?.message || (r.error ? r.error.message : `automations refresh exited ${r.status}`) };
307
+ }
308
+
309
+ // ------------------------------------------------------------ opting out here
310
+
311
+ /** Add or remove `<member>/<id>` in oats-local.yaml `<kind>s.disabled` (`triggers.disabled` or
312
+ * `schedules.disabled`), keeping the rest of the file (comments included) as written.
313
+ * `validate(value)` → problems, checked before any write. */
314
+ export function setDisabledHere(localPath, kind, qid, disabled, { validate } = {}) {
315
+ const section = `${kind}s`;
316
+ const text = readFileSync(localPath, "utf8");
317
+ const doc = YAML.parseDocument(text, { keepSourceTokens: true });
318
+ if (doc.errors?.length) throw automationError("E_WORKSPACE_SCHEMA", `${localPath}: ${doc.errors[0].message}`);
319
+ const current = doc.getIn([section, "disabled"]);
320
+ const list = current && typeof current.toJSON === "function" ? current.toJSON() : Array.isArray(current) ? current : [];
321
+ const next = disabled ? [...new Set([...list, qid])].sort() : list.filter((x) => x !== qid);
322
+ if (JSON.stringify(next) === JSON.stringify(list)) return { changed: false, disabled: list };
323
+ if (next.length) doc.setIn([section, "disabled"], next);
324
+ else if (doc.hasIn([section, "disabled"])) {
325
+ doc.deleteIn([section, "disabled"]);
326
+ const rest = doc.get(section);
327
+ if (rest && typeof rest.toJSON === "function" && !Object.keys(rest.toJSON() || {}).length) doc.delete(section);
328
+ }
329
+ const out = doc.toString();
330
+ const value = parseConfigData(out, { origin: { kind: "local", path: localPath } }).value;
331
+ const problems = validate ? validate(value) : [];
332
+ if (problems.length) throw automationError("E_WORKSPACE_SCHEMA", `the rewritten ${localPath} would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`);
333
+ writeFileSync(localPath, out);
334
+ return { changed: true, disabled: next };
335
+ }
336
+
337
+ // ------------------------------------------------------------ writing a workspace file
338
+
339
+ /** The YAML text of a workspace trigger or schedule file: the shared header, then the kind's body. */
340
+ export function automationFileText(desc, { id, description, runsOn, owner, body }) {
341
+ return YAML.stringify({ kind: desc.fileKind, schemaVersion: 1, id, ...(description ? { description } : {}), runsOn, owner, ...body }, { lineWidth: 0 });
342
+ }
343
+ /** Where `oats trigger|schedule add --workspace <member>` writes: the git checkout containing
344
+ * `dir` when its origin remote IS that member's repository (`sameRepo(url)` decides), else null
345
+ * (the caller prints the file instead). → { root, file, rel } | null */
346
+ export function memberCheckoutFor(dir, desc, id, { sameRepo }) {
347
+ const top = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
348
+ if (top.status !== 0) return null;
349
+ const root = top.stdout.trim();
350
+ const url = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
351
+ if (url.status !== 0 || !sameRepo(url.stdout.trim())) return null;
352
+ const rel = `${desc.folder}/${id}.yaml`;
353
+ return { root, file: join(root, rel), rel };
354
+ }
355
+
356
+ // ------------------------------------------------------------ list rows
357
+
358
+ /** The fields every trigger row and every schedule row share (docs/desktop-cli-api.md): identity,
359
+ * origin, who and where. Each kind's module adds its own (`kind`, the soul, the task, the event
360
+ * or the cron, the last and next run). */
361
+ export function baseRow(a) {
362
+ return {
363
+ id: a.id, name: a.name, origin: a.origin, description: a.description ?? null,
364
+ owner: a.owner ?? null, runsOn: a.runsOn ?? null,
365
+ runsHere: a.placement.runsHere, reason: a.placement.reason, ...(a.placement.detail ? { reasonDetail: a.placement.detail } : {}),
366
+ enabledHere: a.placement.enabledHere,
367
+ ...(a.invalid ? { invalid: a.invalid } : {}),
368
+ };
369
+ }