@awebai/oats 0.27.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
@@ -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
+ }