@awebai/oats 0.28.0 → 0.29.1

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 (70) hide show
  1. package/bin/oats.mjs +298 -107
  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 +277 -15
  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/release-notes/v0.29.1.md +79 -0
  47. package/docs/schedules.md +133 -5
  48. package/docs/souls-and-instances.md +4 -6
  49. package/docs/workspaces.md +11 -2
  50. package/lib/automations.mjs +369 -0
  51. package/lib/core.mjs +65 -154
  52. package/lib/instance-git.mjs +30 -1
  53. package/lib/instance-inspect.mjs +12 -4
  54. package/lib/instance-resolution.mjs +31 -182
  55. package/lib/materialize.mjs +5 -7
  56. package/lib/operator-dispatch.mjs +1 -2
  57. package/lib/packages.mjs +17 -0
  58. package/lib/remote.mjs +21 -1
  59. package/lib/resolve.mjs +51 -7
  60. package/lib/schedule.mjs +211 -41
  61. package/lib/triggers.mjs +182 -49
  62. package/lib/workspace.mjs +1 -1
  63. package/package-catalog.json +6 -4
  64. package/package.json +1 -1
  65. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  66. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  67. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  68. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  69. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  70. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/bin/oats.mjs CHANGED
@@ -27,10 +27,10 @@ import {
27
27
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
28
28
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
29
29
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
30
- findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, explicitInstanceName, findModuleCapabilityAgent, capabilityAgentFromDir, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
30
+ findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
31
  } from "../lib/core.mjs";
32
32
  import {
33
- writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages,
33
+ writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
34
34
  classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
35
35
  import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings } from "../lib/workspace.mjs";
36
36
  import { parseConfigData } from "../lib/config-data.mjs";
@@ -39,7 +39,8 @@ import YAML from "yaml";
39
39
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
40
40
  import { spawnSync as spawnSyncProc } from "node:child_process";
41
41
  import { tickTriggers } from "../lib/triggers.mjs";
42
- import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
42
+ import * as A from "../lib/automations.mjs";
43
+ import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, testSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, scopeAutomations, scheduleKind, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
43
44
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
44
45
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
45
46
 
@@ -70,7 +71,7 @@ function expandInlineValues(argv) {
70
71
  const { argv: args, problem: argvProblem } = expandInlineValues(rawArgs);
71
72
  let cmd = args[0];
72
73
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
73
- const KERNEL_COMMANDS = new Set(["capture", "capabilities", "doctor", "inspect", "instance", "operation", "package", "readiness", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "update", "version", "workspace"]);
74
+ const KERNEL_COMMANDS = new Set(["automations", "trigger", "capture", "capabilities", "doctor", "inspect", "instance", "operation", "package", "readiness", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "update", "version", "workspace"]);
74
75
  /** Commands whose argv another parser reads (packages/record and packages/experimental parse process.argv). */
75
76
  const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]);
76
77
  /** Commands `--server <id>` runs on a registered server. */
@@ -957,7 +958,8 @@ function instanceCmd() {
957
958
  console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
958
959
  console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
959
960
  console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
960
- for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
961
+ const lines = (f) => (f.binary ? " binary" : Number.isInteger(f.additions) ? ` +${f.additions} -${f.deletions}` : "");
962
+ for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path}${lines(f)} [${f.id}]`);
961
963
  for (const n of observed.notes) console.log(` note: ${n}`);
962
964
  return;
963
965
  }
@@ -1072,6 +1074,10 @@ const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.repla
1072
1074
  const teamLabel = (team) => team ?? "unassigned";
1073
1075
  const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1074
1076
 
1077
+ /** A soul's spawn default harness and model (feature desktop-facts): its definition's, else the kernel's (pi, the
1078
+ * harness's native model) — what `oats spawn <soul>` takes with no --harness/--model/--launch-config. */
1079
+ const soulDefaults = (def) => ({ harness: def?.harness ?? "pi", model: def?.model ?? null, harnessFrom: def?.harness ? "soul" : "kernel-default" });
1080
+
1075
1081
  /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1076
1082
  * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1077
1083
  * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE). */
@@ -1080,15 +1086,15 @@ function workspaceItems(discovery, lock) {
1080
1086
  const capabilities = [];
1081
1087
  for (const m of discovery.members) {
1082
1088
  if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
1083
- for (const s of m.souls) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1089
+ for (const s of m.souls) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null, ...soulDefaults(s.definition) });
1084
1090
  for (const c of m.capabilities) capabilities.push({ name: c.name, origin: originOf(c), kind: "member", repoKey: c.repoKey, commit: c.commit, team: teamLabel(c.team), private: c.private, path: c.path, layer: c.manifest.layer ?? null, version: c.manifest.version ?? null });
1085
1091
  }
1086
1092
  for (const ext of discovery.external || []) {
1087
1093
  const s = ext.soul;
1088
- souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1094
+ souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null, ...soulDefaults(s.definition) });
1089
1095
  }
1090
1096
  for (const s of discovery.packageSouls || []) {
1091
- souls.push({ name: s.name, qualifiedName: s.qualifiedName, origin: originOf(s), kind: "package", package: s.package, version: s.version, repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? [])], private: false, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1097
+ souls.push({ name: s.name, qualifiedName: s.qualifiedName, origin: originOf(s), kind: "package", package: s.package, version: s.version, repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? [])], private: false, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null, ...soulDefaults(s.definition) });
1092
1098
  }
1093
1099
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1094
1100
  for (const name of entry.capabilities) capabilities.push({ name, origin: originOf({ package: id, version: entry.version }), kind: "package", package: id, version: entry.version, commit: entry.commit, team: teamLabel(null), private: false });
@@ -1164,11 +1170,17 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1164
1170
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
1165
1171
  // oats-local.yaml + sync is a complete deployment; spawn must not answer E_NO_DEPLOYMENT after it.
1166
1172
  try { mkdirSync(join(ctx.deploymentDir, "agents"), { recursive: true }); } catch { /* reported by spawn's E_NO_DEPLOYMENT remedy if it matters */ }
1173
+ // Workspace automations (0.29.0): discovered with the members and the new lock (a trigger
1174
+ // template instantiates at the locked package commit), kept as the snapshot the host tick reads.
1175
+ let automations;
1176
+ try { automations = await takeAutomationSnapshot(ctx, discovery, lock); }
1177
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1178
+ problems.push(...automations.problems);
1167
1179
  const members = memberRows(discovery);
1168
1180
  const packages = packageRows(lock);
1169
1181
  const changes = resolved.changes;
1170
1182
  const items = workspaceItems(discovery, lock);
1171
- const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, problems, warnings: discovery.warnings ?? [] };
1183
+ const report = { syncApi: 1, automations: automationCounts(automations), standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, problems, warnings: discovery.warnings ?? [] };
1172
1184
  return { report, lock, discovery, items, lockFile, problems };
1173
1185
  }
1174
1186
 
@@ -1207,11 +1219,91 @@ function printSyncReport(ctx, synced) {
1207
1219
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
1208
1220
  for (const c of items.capabilities.filter((c) => c.kind === "member")) { const t = teams.get(c.team) || { souls: 0, capabilities: 0 }; t.capabilities++; teams.set(c.team, t); }
1209
1221
  console.log(`teams ${[...teams.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([team, n]) => `${team} ${n.souls} soul${n.souls === 1 ? "" : "s"}${n.capabilities ? `, ${n.capabilities} capabilit${n.capabilities === 1 ? "y" : "ies"}` : ""}`).join(" · ") || "(none)"}`);
1222
+ const ac = report.automations;
1223
+ if (ac.triggers || ac.schedules) console.log(`automations ${ac.triggers} trigger${ac.triggers === 1 ? "" : "s"}, ${ac.schedules} schedule${ac.schedules === 1 ? "" : "s"} in the members (oats trigger list · oats schedule list)`);
1210
1224
  for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
1211
1225
  for (const w of discovery.warnings ?? []) console.log(`warning ${w.code} ${w.message}`);
1212
1226
  console.log(`\nlock ${shortPath(lockFile)}`);
1213
1227
  }
1214
1228
 
1229
+ /** Discover the workspace automations of a discovery and write the snapshot the host tick reads
1230
+ * (lib/automations.mjs). A trigger's `from:` instantiates the package template at the lock's
1231
+ * commit; a schedule is checked against this deployment. → the snapshot */
1232
+ async function takeAutomationSnapshot(ctx, discovery, lock) {
1233
+ const kinds = await automationKinds(ctx.deploymentDir, lock, ctx.remoteOptions);
1234
+ const found = await A.discoverAutomations(discovery, { remote: kinds.remote, memberName: memberLabel, kinds: [kinds.trigger, kinds.schedule], previous: A.readSnapshot(ctx.deploymentDir) });
1235
+ const snap = A.snapshotOf(discovery, found, { souls: A.soulIndexOf(discovery, memberLabel) });
1236
+ // A workspace with no triggers or schedules leaves nothing behind (onboard creates what the
1237
+ // kernel needs and nothing else); a snapshot that existed is replaced, so a removal lands.
1238
+ const empty = !snap.triggers.length && !snap.schedules.length && !snap.problems.length;
1239
+ if (!empty || A.readSnapshot(ctx.deploymentDir)) A.writeSnapshot(ctx.deploymentDir, snap);
1240
+ return snap;
1241
+ }
1242
+ /** The two kinds' descriptors (lib/triggers.mjs triggerKind, lib/schedule.mjs scheduleKind). */
1243
+ async function automationKinds(dep, lock, remoteOptions) {
1244
+ const T = await import("../lib/triggers.mjs");
1245
+ const { bindRemote } = await import("../lib/packages.mjs");
1246
+ const remote = bindRemote(remoteModule, remoteOptions);
1247
+ return { remote, trigger: T.triggerKind({ lock, remote }), schedule: scheduleKind({ dep }) };
1248
+ }
1249
+ const automationCounts = (snap) => ({ triggers: snap.triggers.length, schedules: snap.schedules.length, problems: snap.problems.length, takenAt: snap.takenAt });
1250
+
1251
+ /** `oats automations refresh [--dir] [--json]`: re-discover the workspace automations and rewrite
1252
+ * the snapshot (what `oats sync` does for them, without touching the lock). The host tick runs it
1253
+ * when the snapshot is ten minutes old. */
1254
+ async function automationsCmd() {
1255
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1256
+ if (args[1] !== "refresh") return bail("E_USAGE", "usage: oats automations refresh [--dir <deployment>] [--json]");
1257
+ const ctx = workspaceContext(bail);
1258
+ const discovery = await discoverForCli(ctx, bail);
1259
+ let lock;
1260
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1261
+ let snap;
1262
+ try { snap = await takeAutomationSnapshot(ctx, discovery, lock); }
1263
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1264
+ const result = { automationsApi: A.AUTOMATIONS_API, snapshot: A.snapshotPath(ctx.deploymentDir), ...automationCounts(snap), problems: snap.problems };
1265
+ if (JSON_MODE) return jsonOk(result);
1266
+ console.log(`automations ${result.triggers} trigger(s), ${result.schedules} schedule(s) — snapshot ${shortPath(result.snapshot)}`);
1267
+ for (const p of snap.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
1268
+ }
1269
+
1270
+ /** `oats trigger|schedule add --workspace <member> --runs-on <host> --owner <host>/<login>`: the
1271
+ * workspace file, written into the member's checkout when `--dir` (or the cwd) is inside one, else
1272
+ * printed. What is written must read back as the same automation (parseAutomationFile + expand). */
1273
+ async function addWorkspaceAutomation(desc, { id, body }) {
1274
+ const bail = (code, msg, details) => { throw Object.assign(new Error(msg), { code, details }); };
1275
+ const member = flag("workspace"), runsOn = flag("runs-on"), owner = flag("owner"), description = flag("description");
1276
+ if (typeof member !== "string" || !member) bail("E_BAD_ARGS", "--workspace needs a member name");
1277
+ if (typeof runsOn !== "string" || !A.HOST_NAME_RE.test(runsOn)) bail("E_BAD_ARGS", "--runs-on <host>: the host.name (oats-local.yaml) of the machine that runs it");
1278
+ if (!A.parseOwner(owner)) bail("E_BAD_ARGS", "--owner <host>/<login>: the GitHub account it acts as, e.g. github.com/acme-kb-bot");
1279
+ const ctx = workspaceContext(bail);
1280
+ const discovery = await discoverForCli(ctx, bail);
1281
+ const row = (discovery.members || []).find((m) => m.confirmed && (memberLabel(m.key) === member || m.key === member));
1282
+ if (!row) bail("E_AUTOMATION_MEMBER", `${member} is not a confirmed member of this workspace (members: ${(discovery.members || []).filter((m) => m.confirmed).map((m) => memberLabel(m.key)).join(", ") || "none"})`, { member });
1283
+ const name = memberLabel(row.key);
1284
+ const content = A.automationFileText(desc, { id, description: typeof description === "string" ? description : undefined, runsOn, owner, body });
1285
+ const parsed = A.parseAutomationFile(desc, { stem: id, path: `${desc.folder}/${id}.yaml`, bytes: Buffer.from(content), member: name, repoKey: row.key, commit: row.commit });
1286
+ if (parsed.problem) bail(parsed.problem.code, parsed.problem.message, { path: parsed.problem.path });
1287
+ await desc.expand(parsed.entry);
1288
+ const sameRepo = (url) => { try { return remoteModule.parseRepoRef(url).key === row.key; } catch { return false; } };
1289
+ const at = A.memberCheckoutFor(dirFlag(), desc, id, { sameRepo });
1290
+ const file = { member: name, repoKey: row.key, path: `${desc.folder}/${id}.yaml`, content };
1291
+ if (!at) return { file, written: false, id: `${name}/${id}` };
1292
+ if (existsSync(at.file)) bail(desc.kind === "trigger" ? "E_TRIGGER_EXISTS" : "E_SCHEDULE_EXISTS", `${at.file} already exists; edit it in place`, { path: at.file });
1293
+ mkdirSync(dirname(at.file), { recursive: true });
1294
+ writeFileSync(at.file, content);
1295
+ return { file: { ...file, written: at.file }, written: true, id: `${name}/${id}` };
1296
+ }
1297
+ const printWorkspaceAdd = (r) => r.written
1298
+ ? `wrote ${shortPath(r.file.written)} — commit and push it; after \`oats sync\` (or within ten minutes) ${r.id} runs on its host`
1299
+ : `# ${r.file.path} in ${r.file.member} (${r.file.repoKey}) — this directory is not a checkout of it; save this there, commit and push:\n${r.file.content}`;
1300
+ /** Enable/disable a WORKSPACE trigger or schedule on this host: oats-local.yaml
1301
+ * `triggers.disabled` / `schedules.disabled`. */
1302
+ function setDisabledHereCli(ws, kind, qid, enabled) {
1303
+ const { path } = loadLocal(ws);
1304
+ return A.setDisabledHere(path, kind, qid, !enabled, { validate: validateLocal });
1305
+ }
1306
+
1215
1307
  /** `oats sync [--dir] [--json]` — contract §6. */
1216
1308
  async function syncCmd() {
1217
1309
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1325,6 +1417,10 @@ async function workspaceCmd() {
1325
1417
  const unsynced = declared.filter((id) => !locked.has(id));
1326
1418
  const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
1327
1419
  const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems, warnings: discovery.warnings ?? [] };
1420
+ // Workspace automations (0.29.0), from the snapshot `oats sync` took, placed on this host.
1421
+ const actx = scopeAutomations(ctx.deploymentDir, {});
1422
+ result.automations = { host: actx.host.name, snapshot: actx.snapshot ? { takenAt: actx.snapshot.takenAt, problems: actx.snapshot.problems.length } : null, rows: [...actx.triggers, ...actx.schedules].map((a) => ({ kind: a.kind, id: a.id, runsOn: a.runsOn, owner: a.owner, runsHere: a.placement.runsHere, reason: a.placement.reason, enabledHere: a.placement.enabledHere, origin: a.origin, ...(a.invalid ? { invalid: a.invalid } : {}) })) };
1423
+ await workspaceStatusFacts(result, discovery, lock, ctx);
1328
1424
  if (JSON_MODE) { jsonOk(result); return; }
1329
1425
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1330
1426
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
@@ -1337,10 +1433,47 @@ async function workspaceCmd() {
1337
1433
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
1338
1434
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
1339
1435
  if (result.external.length) console.log(`\nExternal: ${result.external.map((e) => `${e.soul} (${e.source.replace(/@([0-9a-f]{40})$/, (_, o) => `@${short(o)}`)}, ${e.team})`).join(" ")}`);
1436
+ if (result.automations.rows.length) {
1437
+ console.log(`\nAutomations (this host: ${result.automations.host ?? "unnamed — set host.name in oats-local.yaml"}):`);
1438
+ printTable(["kind", "id", "runs on", "owner", "here"], result.automations.rows.map((r) => [r.kind, r.id, r.runsOn, r.owner, r.runsHere ? "runs here" : r.invalid ? "invalid" : r.reason ?? "disabled here"]));
1439
+ }
1340
1440
  if (discovery.problems.length) { console.log("\nProblems:"); for (const p of discovery.problems) console.log(` ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`); }
1341
1441
  if (discovery.warnings?.length) { console.log("\nWarnings:"); for (const w of discovery.warnings) console.log(` ${w.code} ${w.message}`); }
1342
1442
  }
1343
1443
 
1444
+ /** `oats workspace status` facts (feature desktop-facts): the workspace and team defaults as rows; this
1445
+ * computer (member clones and the rule that found each, disabled souls, the lock); file locations with
1446
+ * browsable urls; each catalog package's newer version, when the official catalog has one. */
1447
+ async function workspaceStatusFacts(result, discovery, lock, ctx) {
1448
+ const { memberCloneOf } = await import("../lib/instance-resolution.mjs");
1449
+ const { browseUrl } = remoteModule;
1450
+ const defaults = discovery.standalone === true ? {} : (discovery.workspace?.defaults ?? {});
1451
+ const rows = (entries) => Object.entries(entries && typeof entries === "object" ? entries : {}).map(([name, v]) => ({ name, from: v === "off" ? null : v?.from ?? null, off: v === "off" })).sort((a, b) => (a.name < b.name ? -1 : 1));
1452
+ const slot = (d) => (d && typeof d === "object" ? Object.entries(d).map(([name, v]) => ({ name, from: v?.from ?? null }))[0] ?? null : d ?? null);
1453
+ result.defaults = {
1454
+ slots: Object.fromEntries(LAYERS.map((l) => [l, slot(defaults[l])])),
1455
+ capabilities: rows(defaults.capabilities),
1456
+ byTeam: Object.fromEntries(Object.entries(defaults.byTeam && typeof defaults.byTeam === "object" ? defaults.byTeam : {}).map(([label, t]) => [label, { capabilities: rows(t?.capabilities) }])),
1457
+ };
1458
+ result.workspace.file = discovery.standalone === true ? null : { path: "oats-workspace.yaml", url: browseUrl(discovery.key, discovery.commit, "oats-workspace.yaml") };
1459
+ for (const m of result.members) Object.assign(m, { url: browseUrl(m.key, m.commit), membershipFile: { path: "oats-membership.yaml", url: browseUrl(m.key, m.commit, "oats-membership.yaml") } });
1460
+ result.clones = result.members.map((m) => {
1461
+ try { return { key: m.key, name: m.name, ...memberCloneOf(ctx.deploymentDir, ctx.local, m.key) }; }
1462
+ catch (e) { if (e?.code === "E_CLONE_MISMATCH") return { key: m.key, name: m.name, path: null, rule: null, problem: { code: e.code, message: e.message } }; throw e; }
1463
+ });
1464
+ result.disabledSouls = [...(Array.isArray(ctx.local?.souls?.disabled) ? ctx.local.souls.disabled : [])];
1465
+ result.lock = { path: join(ctx.deploymentDir, LOCK_FILE), lockfileVersion: lock.lockfileVersion ?? null };
1466
+ const { compareSemver } = await import("../lib/servers.mjs");
1467
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
1468
+ const cat = catalog && typeof catalog === "object" ? (catalog.packages && typeof catalog.packages === "object" && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
1469
+ for (const p of result.packages) {
1470
+ const ref = p.source === `catalog:${p.id}` ? cat[p.id]?.ref : undefined;
1471
+ const tag = standaloneCatalogVersion(ref);
1472
+ const version = tag ? tag.replace(/^v/, "") : null;
1473
+ p.latest = version && typeof p.version === "string" && compareSemver(version, p.version) > 0 ? { version, ref } : null;
1474
+ }
1475
+ }
1476
+
1344
1477
  /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
1345
1478
  async function itemsCmd(kind) {
1346
1479
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1349,6 +1482,10 @@ async function itemsCmd(kind) {
1349
1482
  let lock;
1350
1483
  try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1351
1484
  const items = workspaceItems(discovery, lock)[kind];
1485
+ // One command's reads at a commit are shared: many souls resolve over the same manifests and listings.
1486
+ const remote = memoizedRemote(remoteModule);
1487
+ if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
1488
+ if (kind === "souls") await soulFacts(items, discovery, lock, ctx, remote);
1352
1489
  const standalone = discovery.standalone === true;
1353
1490
  if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
1354
1491
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
@@ -1359,6 +1496,60 @@ async function itemsCmd(kind) {
1359
1496
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
1360
1497
  }
1361
1498
 
1499
+ /** Capability rows' manifest facts (feature desktop-facts): layer, description, and what each provides
1500
+ * (skills, commands, hooks by name) — member rows from discovery's manifest, package rows from the manifest
1501
+ * at the locked commit (the sync cache; no network beyond sync's). An unreadable package leaves its rows' facts null. */
1502
+ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1503
+ const { capabilityProvides, lockedPackageCapabilities, memberRef } = await import("../lib/resolve.mjs");
1504
+ const remoteOptions = ctx.remoteOptions;
1505
+ const catalog = (() => { try { return officialPackageCatalog(); } catch { return null; } })();
1506
+ const packages = new Map();
1507
+ for (const [id, entry] of Object.entries(lock?.packages || {})) {
1508
+ try { packages.set(id, await lockedPackageCapabilities(id, entry, { catalog, remote, remoteOptions })); } catch { packages.set(id, null); }
1509
+ }
1510
+ // A member capability's fingerprint is its Git tree at the commit (one listing per member commit); a
1511
+ // package's is the package integrity (the lock).
1512
+ const trees = new Map();
1513
+ const treeOf = async ({ repoKey, commit }, ref, dir) => {
1514
+ const key = `${repoKey}\0${commit}`;
1515
+ if (!trees.has(key)) {
1516
+ const member = discovery.members.find((m) => m.key === repoKey);
1517
+ const dirs = rows.filter((r) => r.kind !== "package" && r.repoKey === repoKey && r.commit === commit).map((r) => member?.capabilities.find((c) => c.name === r.name)?.path).filter(Boolean);
1518
+ trees.set(key, remoteModule.remoteTreeOids(ref, commit, [...new Set(dirs)], remoteOptions).catch(() => new Map()));
1519
+ }
1520
+ return (await trees.get(key)).get(dir) ?? null;
1521
+ };
1522
+ for (const row of rows) {
1523
+ let manifest = null, ref = null, dir = null;
1524
+ if (row.kind === "package") {
1525
+ const read = packages.get(row.package), cap = read?.capabilities.find((c) => c.name === row.name);
1526
+ if (cap) ({ manifest, dir } = cap), ref = read.ref;
1527
+ } else {
1528
+ const cap = discovery.members.find((m) => m.key === row.repoKey)?.capabilities.find((c) => c.name === row.name);
1529
+ if (cap) { manifest = cap.manifest; dir = cap.path; ref = memberRef(discovery, remoteModule, row.repoKey); }
1530
+ }
1531
+ const provides = manifest ? await capabilityProvides({ ref, commit: row.commit, dir, manifest, remote, remoteOptions }) : { skills: null, commands: null, hooks: null };
1532
+ const repoKey = row.kind === "package" && ref ? (() => { try { return remoteModule.parseRepoRef(ref).key; } catch { return null; } })() : row.repoKey;
1533
+ const file = dir ? { path: `${dir}/oats.json`, url: remoteModule.browseUrl(repoKey, row.commit, `${dir}/oats.json`) } : null;
1534
+ const tree = row.kind !== "package" && ref && dir ? await treeOf(row, ref, dir) : null;
1535
+ Object.assign(row, { layer: manifest?.layer ?? row.layer ?? null, description: manifest?.description ?? null, ...provides, file, tree });
1536
+ }
1537
+ }
1538
+
1539
+ /** Souls rows' spawnability (feature desktop-facts): the refusal a spawn of each would meet, computed from
1540
+ * discovery and the lock without spawning (soulSpawnability). */
1541
+ async function soulFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1542
+ const { soulSpawnability } = await import("../lib/instance-resolution.mjs");
1543
+ const entryOf = (row) => row.kind === "package" ? (discovery.packageSouls || []).find((p) => p.qualifiedName === row.qualifiedName)
1544
+ : row.kind === "external" ? (discovery.external || []).map((e) => e.soul).find((x) => x.name === row.name && x.repoKey === row.repoKey)
1545
+ : discovery.members.find((m) => m.key === row.repoKey)?.souls.find((x) => x.name === row.name);
1546
+ for (const row of rows) {
1547
+ const entry = entryOf(row);
1548
+ row.file = { path: `${row.path}/soul.yaml`, url: remoteModule.browseUrl(row.repoKey, row.commit, `${row.path}/soul.yaml`) };
1549
+ Object.assign(row, entry ? await soulSpawnability(ctx.local, discovery, lock, entry, { remoteOptions: ctx.remoteOptions, remote }) : { spawnable: false, problem: { code: "E_SOUL_UNKNOWN", message: `${row.name} is not in this discovery` } });
1550
+ }
1551
+ }
1552
+
1362
1553
  // ---------- roster: status / spawn / retire / create ----------
1363
1554
  /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
1364
1555
  * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
@@ -1383,7 +1574,7 @@ async function statusDrift(data) {
1383
1574
  for (const a of data) {
1384
1575
  if (!a.dir) continue;
1385
1576
  try { const stamp = JSON.parse(readFileSync(join(a.dir, ".oats-soul-source.json"), "utf8")); if (stamp && typeof stamp.repoKey === "string") souls.set(a.name, { repoKey: stamp.repoKey, commit: typeof stamp.commit === "string" ? stamp.commit : null, path: stamp.path ?? null, ...(typeof stamp.package === "string" ? { package: stamp.package, version: stamp.version ?? null } : {}) }); }
1386
- catch { /* not a workspace soul (classic, capability agent, or unreadable stamp) */ }
1577
+ catch { /* not a workspace soul (classic, or an unreadable stamp) */ }
1387
1578
  }
1388
1579
  const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
1389
1580
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
@@ -1546,7 +1737,7 @@ async function spawnCmd() {
1546
1737
  // the per-commit cache, or fetches it to a temporary copy (reported as soulFetched).
1547
1738
  const providerPairs = [];
1548
1739
  for (let i = 0; i < args.length; i++) if (args[i] === "--provider") { if (!args[i + 1] || !args[i + 2]) bail("E_BAD_ARGS", "--provider needs <capability> <key>=<value>"); providerPairs.push([args[i + 1], args[i + 2]]); i += 2; }
1549
- let wsPrepared, soulFetched = false, wsSoulUnknown = null, wsDiscovery;
1740
+ let wsPrepared, soulFetched = false, wsDiscovery;
1550
1741
  {
1551
1742
  let discovery = null;
1552
1743
  try {
@@ -1581,50 +1772,12 @@ async function spawnCmd() {
1581
1772
  const file = officialCatalogFile();
1582
1773
  bail(e.code, `${e.details?.capability ?? "oats.core"}: the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file}) — standalone spawns resolve only the kernel's default package from the catalog`, { ...(e.details ?? {}), standalone: true, reason: "no-catalog", catalog: file });
1583
1774
  }
1584
- // Not a workspace soul: a capability-defined agent (a module's `agents:`
1585
- // soul, resolved below from a materialized copy) may still answer to this name.
1586
- if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
1587
- else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
1775
+ if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
1588
1776
  else throw e;
1589
1777
  }
1590
1778
  }
1591
1779
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
1592
1780
  if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
1593
- // Capability agent (lead decision c3 Q1): a prepared spawn of its providing module only.
1594
- let capabilityPrepared, capabilityPkg = null;
1595
- if (!agent) {
1596
- // Workspace model: the agent is declared by a capability some INSTANCE already
1597
- // materialized (the --parent home first, then any home under this root) —
1598
- // OKF's memory-harvest worker spawned by a knowledge source, for example.
1599
- const anchorName = flag("parent") || flag("relative-to");
1600
- const anchorHome = anchorName ? (findInstanceHome(root, String(anchorName)) ?? null) : null;
1601
- let modAgent;
1602
- try { modAgent = findModuleCapabilityAgent(root, name, { anchorHome }); }
1603
- catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
1604
- if (modAgent) {
1605
- agent = modAgent;
1606
- note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`);
1607
- } else {
1608
- // No instance carries it: resolve from the deployment's LOCK — a locked
1609
- // package whose capability declares agents/<name> is fetched into the
1610
- // deployment's module store and read from there.
1611
- try {
1612
- const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1613
- const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), discovery: wsDiscovery, catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1614
- if (hit) {
1615
- agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
1616
- if (agent) { capabilityPkg = hit; note(`(capability agent: "${name}" from ${hit.capability} — package ${hit.package} v${hit.version}, fetched to ${shortPath(hit.dir)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`); }
1617
- }
1618
- } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
1619
- }
1620
- if (agent) {
1621
- try {
1622
- const { prepareCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1623
- capabilityPrepared = await prepareCapabilityAgent(dirFlag(), agent, { discovery: wsDiscovery, pkg: capabilityPkg, remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1624
- } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
1625
- }
1626
- }
1627
- if (!agent && wsSoulUnknown) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
1628
1781
  checkDirectoryOptions(requestedWork || agent?.work);
1629
1782
  if (!agent) bail("E_UNKNOWN_AGENT", `unknown agent "${name}" (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"}) — a soul is a member repository's souls/<name>; add it there and run \`oats sync\``);
1630
1783
  for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
@@ -1653,9 +1806,6 @@ async function spawnCmd() {
1653
1806
  if ((flag("work") === "attached") && relation && relation !== "child") bail("E_BAD_ARGS", "attached agents are always children of the work-tree owner — only --parent <instance> (or --relation child) is valid with --work attached");
1654
1807
  // NOTE: explicit "unrelated" is passed through to the kernel.
1655
1808
  if (relativeTo && relation !== "unrelated") {
1656
- // findInstanceHome also sees capability-defined agents' instance homes
1657
- // (<root>/<name>/ without a soul) — e.g. a reviewer passing
1658
- // --parent "$OATS_INSTANCE" from a capability agent.
1659
1809
  if (!findInstanceHome(root, relativeTo)) bail(parent ? "E_PARENT_NOT_FOUND" : "E_RELATIVE_NOT_FOUND", `${parent ? "--parent" : "--relative-to"} "${relativeTo}" does not match any known instance`);
1660
1810
  }
1661
1811
  const taskText = flag("task");
@@ -1715,10 +1865,10 @@ async function spawnCmd() {
1715
1865
  // convention <deployment>/<member name>. Never an ambient Git checkout around the
1716
1866
  // deployment. Resolved once here so a preview sees exactly what the apply would.
1717
1867
  let preparedRepo;
1718
- if (wsPrepared || capabilityPrepared) {
1868
+ if (wsPrepared) {
1719
1869
  try {
1720
1870
  const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
1721
- prepared = wsPrepared ?? capabilityPrepared;
1871
+ prepared = wsPrepared;
1722
1872
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
1723
1873
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
1724
1874
  prepared.toCapabilityRows = toCapabilityRows;
@@ -1782,7 +1932,7 @@ async function spawnCmd() {
1782
1932
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1783
1933
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1784
1934
  if (e?.code === "E_INSTANCE_NAME_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home ?? null, ...(e.session ? { session: e.session } : {}) }); throw e; }
1785
- if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message); throw e; }
1935
+ if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message, e.details); throw e; }
1786
1936
  if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
1787
1937
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
1788
1938
  }
@@ -1935,7 +2085,7 @@ function retireCmd() {
1935
2085
  /** `oats schedule ...`: workspace-scoped definitions, host-owned execution
1936
2086
  * (lib/schedule.mjs). Every subcommand answers the envelope; nothing here
1937
2087
  * launches unless a job is due or run-now is asked. */
1938
- function scheduleCmd() {
2088
+ async function scheduleCmd() {
1939
2089
  const sub = args[1];
1940
2090
  const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
1941
2091
  // One schedule-owning scope for a directory: its deployment (the directory
@@ -1944,7 +2094,11 @@ function scheduleCmd() {
1944
2094
  let scope;
1945
2095
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1946
2096
  const io = { hostStatus: () => hostUnitStatus() };
1947
- const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2097
+ // The workspace automations of this deployment (the snapshot) placed on this host.
2098
+ let actx;
2099
+ const ctx = () => (actx ??= scopeAutomations(ws(), { io }));
2100
+ const isWorkspace = (qid) => A.splitId(qid).scope !== "local";
2101
+ const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
1948
2102
  const readSpec = () => {
1949
2103
  const inline = flag("spec-json");
1950
2104
  if (inline && inline !== true) { try { return JSON.parse(inline); } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `--spec-json is not valid JSON: ${e.message}`, { field: "file" }); } }
@@ -1956,20 +2110,41 @@ function scheduleCmd() {
1956
2110
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
1957
2111
  try {
1958
2112
  switch (sub) {
1959
- case "list": { const r = listSchedules(ws(), io); out(r); if (!JSON_MODE && r.triggers.count) console.log(`${r.triggers.count} trigger${r.triggers.count === 1 ? " is" : "s are"} not listed here: ${r.triggers.command}`); return; }
1960
- case "show": return out({ schedule: describeSchedule(ws(), needId(), io) });
1961
- case "add": { const spec = readSpec(); if (id && spec.id === undefined) spec.id = id; if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" }); return out({ schedule: addSchedule(ws(), spec, io) }); }
2113
+ case "list": { const r = listSchedules(ws(), io, { ctx: ctx() }); out(r); if (!JSON_MODE && r.triggers.count) console.log(`${r.triggers.count} trigger${r.triggers.count === 1 ? " is" : "s are"} not listed here: ${r.triggers.command}`); return; }
2114
+ case "show": return out({ schedule: describeSchedule(ws(), needId(), io, { ctx: ctx() }) });
2115
+ case "test": return out({ test: testSchedule(ws(), needId(), io, { ctx: ctx() }) }, (r) => `${r.test.qualifiedId}: ${r.test.placement.runsHere ? "runs here" : `not here (${r.test.placement.reason ?? "disabled here"})`}${r.test.soul ? `; soul ${r.test.soul.name} ${r.test.soul.resolves ? "resolves" : `does NOT resolve (${r.test.soul.error?.code})`}` : ""}; next due ${r.test.nextDue ?? "never"}${r.test.problems.length ? `\n ${r.test.problems.join("\n ")}` : ""}\n(spawned nothing)`);
2116
+ case "add": {
2117
+ const spec = readSpec();
2118
+ if (flag("workspace") !== undefined) {
2119
+ const wid = id ?? spec.id;
2120
+ if (typeof wid !== "string") throw scheduleError("E_BAD_ARGS", "oats schedule add <id> --workspace <member> …: the id names the file (oats-schedules/<id>.yaml)");
2121
+ const { id: _i, enabled: _e, kind, ...rest } = spec; void _i; void _e;
2122
+ const r = await addWorkspaceAutomation(scheduleKind({ dep: ws() }), { id: wid, body: { run: kind ?? "spawn", ...rest } });
2123
+ return out(r, printWorkspaceAdd);
2124
+ }
2125
+ if (id && spec.id === undefined) spec.id = id;
2126
+ if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" });
2127
+ return out({ schedule: addSchedule(ws(), spec, io) });
2128
+ }
1962
2129
  case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
1963
- case "enable": return out({ schedule: setScheduleEnabled(ws(), needId(), true, io) });
1964
- case "disable": return out({ schedule: setScheduleEnabled(ws(), needId(), false, io) });
1965
- case "run": return out(runScheduleNow(ws(), needId(), { io, force: args.includes("--force") }));
2130
+ case "enable":
2131
+ case "disable": {
2132
+ const on = sub === "enable";
2133
+ if (!isWorkspace(needId())) return out({ schedule: setScheduleEnabled(ws(), id, on, io) });
2134
+ describeSchedule(ws(), id, io, { ctx: ctx() }); // an unknown id is refused before the file changes
2135
+ setDisabledHereCli(ws(), "schedule", A.qualifiedId(id), on);
2136
+ actx = undefined;
2137
+ return out({ schedule: describeSchedule(ws(), id, io, { ctx: ctx() }) });
2138
+ }
2139
+ case "run": return out(runScheduleNow(ws(), needId(), { io, force: args.includes("--force"), ctx: ctx() }));
1966
2140
  case "remove": return out(removeSchedule(ws(), needId(), { force: args.includes("--force") }));
1967
- case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear") }));
2141
+ case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear"), ctx: ctx() }));
1968
2142
  case "tick": {
1969
2143
  const dryRun = args.includes("--dry-run");
1970
2144
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
1971
2145
  const reg = readRegistry();
1972
- const considered = withHostLock(() => [...tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun }), ...tickTriggers(ws(), { io, dryRun })]);
2146
+ const tctx = scopeAutomations(ws(), { io, refresh: !dryRun });
2147
+ const considered = withHostLock(() => [...(tctx.refresh && !tctx.refresh.ok ? [{ workspace: ws(), action: "error", error: `automations refresh: ${tctx.refresh.error}` }] : []), ...tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun, ctx: tctx }), ...tickTriggers(ws(), { io, dryRun, ctx: tctx })]);
1973
2148
  return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
1974
2149
  }
1975
2150
  case "host": {
@@ -1979,7 +2154,7 @@ function scheduleCmd() {
1979
2154
  if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
1980
2155
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
1981
2156
  }
1982
- default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2157
+ default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|test <id>|add <id> --file <spec.json> [--workspace <member> --runs-on <host> --owner <host>/<login>]|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
1983
2158
  }
1984
2159
  } catch (e) {
1985
2160
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
@@ -1998,16 +2173,28 @@ async function triggerCmd() {
1998
2173
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1999
2174
  const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
2000
2175
  const needId = () => { if (!id) throw T.triggerError("E_BAD_ARGS", `oats trigger ${sub} <id>`); return id; };
2001
- const usage = "usage: oats trigger add (--file <trigger.json> | --from <package>:<template> [--set <name>=<value>]… [--id <id>]) | list | show <id> | enable <id> | disable <id> | remove <id> | test <id> | status [<id>] [--dir <deployment>] [--json]";
2002
- const line = (t) => `${t.id} ${t.enabled ? "enabled " : "disabled"} ${t.on?.source ?? "?"} ${t.on?.repo ?? "?"} [${(t.on?.events || []).join(",")}]${t.on?.labels?.length ? ` labels ${t.on.labels.join(",")}` : ""} every ${t.on?.poll ?? "?"} → spawn ${t.spawn?.soul ?? "?"}${t.spawn?.teams?.length ? ` in ${t.spawn.teams.join(",")}` : ""}${t.invalid ? ` INVALID: ${t.invalid.message}` : ""}`;
2176
+ const usage = "usage: oats trigger add (--file <trigger.json> | --from <package>:<template> [--set <name>=<value>]… [--id <id>]) [--workspace <member> --runs-on <host> --owner <host>/<login>] | list | show <id> | enable <id> | disable <id> | remove <id> | test <id> | status [<id>] [--dir <deployment>] [--json] (<id>: local/<id> or <member>/<id>)";
2177
+ const where = (t) => (t.origin?.kind === "workspace" ? (t.runsHere ? `runs here as ${t.owner}` : `${t.reason === "assigned-elsewhere" ? `runs on ${t.runsOn}` : t.reason ?? "disabled here"}`) : t.enabledHere ? "local" : "local, disabled");
2178
+ const line = (t) => `${t.id} ${where(t)} ${t.on?.source ?? "?"} ${t.on?.repo ?? "?"} [${(t.on?.events || []).join(",")}]${t.on?.labels?.length ? ` labels ${t.on.labels.join(",")}` : ""} every ${t.on?.poll ?? "?"} → spawn ${t.spawn?.soul ?? "?"}${t.spawn?.teams?.length ? ` in ${t.spawn.teams.join(",")}` : ""}${t.invalid ? ` INVALID: ${t.invalid.message}` : ""}`;
2179
+ // The workspace automations of this deployment (the snapshot) placed on this host.
2180
+ let actx;
2181
+ const ctx = () => (actx ??= scopeAutomations(ws(), {}));
2182
+ const isWorkspace = (qid) => A.splitId(qid).scope !== "local";
2003
2183
  try {
2004
2184
  switch (sub) {
2005
- case "list": return out(T.listTriggers(ws()), (r) => r.triggers.length ? r.triggers.map(line).join("\n") : "(no triggers — oats trigger add --file <trigger.json> | --from <package>:<template>)");
2006
- case "show": return out({ trigger: T.describeTrigger(ws(), needId()) });
2007
- case "enable": return out({ trigger: T.setTriggerEnabled(ws(), needId(), true) }, (r) => line(r.trigger));
2008
- case "disable": return out({ trigger: T.setTriggerEnabled(ws(), needId(), false) }, (r) => line(r.trigger));
2009
- case "remove": return out(T.removeTrigger(ws(), needId()), (r) => `removed trigger ${r.removed}${r.live.length ? ` (its live instances keep running: ${r.live.join(", ")})` : ""}`);
2010
- case "status": return out(T.triggerStatus(ws(), id), (r) => r.triggers.map((t) => `${t.id} last poll ${t.lastPoll ? `${t.lastPoll.at} ${t.lastPoll.ok ? `ok (${t.lastPoll.matching}/${t.lastPoll.prs} PRs match)` : `FAILED: ${t.lastPoll.error}`}` : "never"} pending ${t.pending.length} fired ${t.firedTotal} live ${t.live.map((l) => l.instance).join(",") || "none"}${t.lastError ? `\n last error ${t.lastError.at}: ${t.lastError.message}` : ""}`).join("\n") || "(no triggers)");
2185
+ case "list": return out(T.listTriggers(ws(), ctx(), { io: { hostStatus: () => hostUnitStatus() } }), (r) => r.triggers.length ? r.triggers.map(line).join("\n") : "(no triggers — oats trigger add --file <trigger.json> | --from <package>:<template>)");
2186
+ case "show": return out({ trigger: T.describeTrigger(ws(), needId(), ctx()) });
2187
+ case "enable":
2188
+ case "disable": {
2189
+ const on = sub === "enable";
2190
+ if (!isWorkspace(needId())) return out({ trigger: T.setTriggerEnabled(ws(), id, on, ctx()) }, (r) => line(r.trigger));
2191
+ T.describeTrigger(ws(), id, ctx()); // an unknown id is refused before the file changes
2192
+ setDisabledHereCli(ws(), "trigger", A.qualifiedId(id), on);
2193
+ actx = undefined;
2194
+ return out({ trigger: T.describeTrigger(ws(), id, ctx()) }, (r) => line(r.trigger));
2195
+ }
2196
+ case "remove": return out(T.removeTrigger(ws(), needId(), ctx()), (r) => `removed trigger ${r.removed}${r.live.length ? ` (its live instances keep running: ${r.live.join(", ")})` : ""}`);
2197
+ case "status": return out(T.triggerStatus(ws(), id, ctx()), (r) => r.triggers.map((t) => `${t.id} last poll ${t.lastPoll ? `${t.lastPoll.at} ${t.lastPoll.ok ? `ok (${t.lastPoll.matching}/${t.lastPoll.prs} PRs match)` : `FAILED: ${t.lastPoll.error}`}` : "never"} pending ${t.pending.length} fired ${t.firedTotal} live ${t.live.map((l) => l.instance).join(",") || "none"}${t.lastError ? `\n last error ${t.lastError.at}: ${t.lastError.message}` : ""}`).join("\n") || "(no triggers)");
2011
2198
  case "test": {
2012
2199
  let workspaceTeams = null;
2013
2200
  try {
@@ -2015,8 +2202,9 @@ async function triggerCmd() {
2015
2202
  const { local } = loadLocal(ws());
2016
2203
  if (typeof local.workspace === "string") workspaceTeams = Object.keys((await observeWorkspace(local.workspace, { remoteOptions: remoteOptionsFromEnv() })).workspace.teams || {});
2017
2204
  } catch { /* reported as unknown (null) */ }
2018
- return out(T.testTrigger(ws(), needId(), { workspaceTeams }), (r) => [
2205
+ return out(T.testTrigger(ws(), needId(), { workspaceTeams, ctx: ctx() }), (r) => [
2019
2206
  `trigger ${r.id}: ${r.ok ? "ready" : "NOT ready"} (nothing was spawned)`,
2207
+ ` placement ${r.placement.runsOn ? `runs on ${r.placement.runsOn} as ${r.placement.owner}; this host is ${r.placement.host ?? "(unnamed)"} — ${r.placement.runsHere ? "runs here" : r.placement.reason ?? "disabled here"}` : "local (this host)"}`,
2020
2208
  ` gh auth ${r.gh.ok ? `ok — ${r.gh.account ?? "?"} via ${r.gh.credentialSource}` : `FAILED${r.gh.detail ? ` — ${r.gh.detail}` : ""}`}`,
2021
2209
  ` repo ${r.repo.key} ${r.repo.readable ? `readable; push ${r.repo.permissions.push} maintain ${r.repo.permissions.maintain} admin ${r.repo.permissions.admin}` : `NOT readable: ${r.repo.error}`}`,
2022
2210
  ` soul ${r.soul.name} ${r.soul.resolves ? `resolves${r.soul.messaging ? ` (messaging ${r.soul.messaging})` : ""}` : `does NOT resolve: ${r.soul.error.code} ${r.soul.error.message}`}`,
@@ -2043,7 +2231,17 @@ async function triggerCmd() {
2043
2231
  if (Object.keys(sets).length) throw T.triggerError("E_BAD_ARGS", "--set fills a package template's parameters; with --file, edit the file");
2044
2232
  try { spec = JSON.parse(readFileSync(file, "utf8")); } catch (e) { throw T.triggerError("E_TRIGGER_INVALID", `${file} is not valid JSON: ${e.message}`, { details: { field: "file" } }); }
2045
2233
  if (idFlag !== undefined) spec.id = idFlag;
2046
- } else spec = await triggerFromPackage(T, String(from), sets, idFlag);
2234
+ }
2235
+ if (flag("workspace") !== undefined) {
2236
+ // A workspace trigger (0.29.0): the file in the member, from/set when made from a template.
2237
+ const kinds = await automationKinds(ws(), readLock(ws()), remoteOptionsFromEnv());
2238
+ const wid = typeof idFlag === "string" ? idFlag : from !== undefined ? String(from).split(":")[1] : spec?.id;
2239
+ if (typeof wid !== "string") throw T.triggerError("E_BAD_ARGS", "--workspace: name the trigger with --id (or an id in the file)");
2240
+ const body = from !== undefined ? { from: String(from), ...(Object.keys(sets).length ? { set: sets } : {}) } : (({ id: _i, kind: _k, enabled: _e, template: _t, createdAt: _c, updatedAt: _u, ...rest }) => rest)(spec);
2241
+ const r = await addWorkspaceAutomation(kinds.trigger, { id: wid, body });
2242
+ return out(r, printWorkspaceAdd);
2243
+ }
2244
+ if (spec === undefined) spec = await triggerFromPackage(T, String(from), sets, idFlag);
2047
2245
  return out({ trigger: T.addTrigger(ws(), spec) }, (r) => `added ${line(r.trigger)}\n(\`oats trigger test ${r.trigger.id}\` checks gh, the repository, the soul and the teams on this host)`);
2048
2246
  }
2049
2247
  default: throw T.triggerError("E_BAD_ARGS", usage);
@@ -2056,28 +2254,8 @@ async function triggerCmd() {
2056
2254
  /** A package trigger template (`triggers: [{ id, file }]` in the locked package's oats-package.json),
2057
2255
  * read at the lock's commit and instantiated with the --set parameters. */
2058
2256
  async function triggerFromPackage(T, from, sets, idFlag) {
2059
- const m = /^([a-z0-9][a-z0-9._-]*):([a-z0-9][a-z0-9._-]*)$/.exec(from);
2060
- if (!m) throw T.triggerError("E_BAD_ARGS", `--from ${JSON.stringify(from)}: write <package>:<template>, e.g. oats.okf:harvest-review`);
2061
- const [, pkg, template] = m;
2062
- const deployment = scheduleScopeOf(dirFlag());
2063
- const lock = readLock(deployment);
2064
- const entry = lock.packages[pkg];
2065
- if (!entry) throw T.triggerError("E_PACKAGE_MISSING", `package ${pkg} is not in ${LOCK_FILE} — declare it in packages: and run \`oats sync\``, { details: { package: pkg } });
2066
- const { lockedPackageRef, bindRemote } = await import("../lib/packages.mjs");
2067
- const ref = lockedPackageRef(entry);
2068
- const remote = bindRemote(remoteModule, remoteOptionsFromEnv());
2069
- const read = async (rel, what) => {
2070
- let bytes;
2071
- try { ({ bytes } = await remote.readRemoteFile(ref, entry.commit, `${entry.path}/${rel}`)); }
2072
- catch (e) { throw T.triggerError(e?.code === "E_REMOTE_PATH_MISSING" ? "E_PACKAGE_MANIFEST" : (e?.code || "E_REMOTE_UNREADABLE"), `${pkg} v${entry.version}: ${what} ${entry.path}/${rel} cannot be read: ${e.message}`, { details: { package: pkg, path: rel } }); }
2073
- try { return JSON.parse(Buffer.from(bytes).toString("utf8")); } catch (e) { throw T.triggerError("E_PACKAGE_MANIFEST", `${pkg} v${entry.version}: ${what} ${entry.path}/${rel} is not valid JSON: ${e.message}`, { details: { package: pkg, path: rel } }); }
2074
- };
2075
- const manifest = await read("oats-package.json", "the package manifest");
2076
- const listed = Array.isArray(manifest.triggers) ? manifest.triggers : [];
2077
- const row = listed.find((t) => t && t.id === template);
2078
- if (!row || typeof row.file !== "string" || !row.file || row.file.split("/").includes("..") || row.file.startsWith("/")) throw T.triggerError("E_TRIGGER_UNKNOWN", `package ${pkg} v${entry.version} has no trigger template ${JSON.stringify(template)} (templates: ${listed.map((t) => t?.id).filter(Boolean).join(", ") || "none"})`, { details: { package: pkg, template, templates: listed.map((t) => t?.id).filter(Boolean) } });
2079
- const doc = await read(row.file, `trigger template ${template}`);
2080
- return T.instantiateTemplate(doc, sets, { id: idFlag, provenance: { package: pkg, version: entry.version, commit: entry.commit, template } });
2257
+ const { bindRemote } = await import("../lib/packages.mjs");
2258
+ return T.packageTriggerTemplate(readLock(scheduleScopeOf(dirFlag())), from, sets, { id: idFlag, remote: bindRemote(remoteModule, remoteOptionsFromEnv()) });
2081
2259
  }
2082
2260
 
2083
2261
  async function sessionCmd() {
@@ -2326,7 +2504,7 @@ async function capabilityCommand() {
2326
2504
  // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2327
2505
  // already fetched exactly this commit; otherwise the command gets none (never ambient).
2328
2506
  const cachedSoul = hit.soul?.commit ? join(hit.deployment, "agents", hit.soul.name, "souls", String(hit.soul.commit).slice(0, 12)) : null;
2329
- return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
2507
+ return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, { settings: hit.settings, origins: hit.resolution?.payloadOrigins?.[hit.module.name] }, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
2330
2508
  }
2331
2509
 
2332
2510
  async function dispatch() {
@@ -2340,14 +2518,14 @@ async function capabilityCommand() {
2340
2518
  // braces: the ids come from instance.json, which spawn wrote from resolved
2341
2519
  // manifests. Null-prototype because the dispatcher indexes it with the
2342
2520
  // namespace the operator typed on the command line.
2343
- let capSettings = Object.create(null);
2521
+ let capSettings = Object.create(null), capOrigins = Object.create(null);
2344
2522
  let deployment = null;
2345
2523
  let soulDir;
2346
2524
  try {
2347
2525
  if (metaFile && existsSync(metaFile)) {
2348
2526
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
2349
2527
  activeIds = (meta.capabilities || []).map((c) => c.id);
2350
- for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
2528
+ for (const c of meta.capabilities || []) { capSettings[c.id] = c.settings || {}; capOrigins[c.id] = c.settingsOrigins || {}; }
2351
2529
  // Workspace-model homes only (lead decision c3 Q2).
2352
2530
  if (isCapturedHome(meta)) { const e = capturedHomeRefusal(instanceHome, "nothing was dispatched"); bail(e.code, e.message, e.details); }
2353
2531
  if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
@@ -2386,14 +2564,14 @@ async function capabilityCommand() {
2386
2564
  const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2387
2565
  teamCtx = homeTeamCtx(live.teams, live.source);
2388
2566
  }
2389
- return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir, soulDir);
2567
+ return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, soulDir);
2390
2568
  }
2391
2569
 
2392
2570
  /** Help / unknown-command / spec validation / exec — shared by every context.
2393
2571
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
2394
2572
  * resolves the directory holding the executable — the operator branch fetches
2395
2573
  * the module tree only when a command is actually going to run). */
2396
- async function runManifestCommand(m, settings, teamCtx, ensureDir, soulDir) {
2574
+ async function runManifestCommand(m, { settings, origins }, teamCtx, ensureDir, soulDir) {
2397
2575
  const sub = args[1];
2398
2576
  const cmds = Object.keys(m.commands);
2399
2577
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -2438,6 +2616,8 @@ async function capabilityCommand() {
2438
2616
  // lifecycle hooks — capabilities read their settings here instead of
2439
2617
  // importing the kernel resolver.
2440
2618
  OATS_SETTINGS: JSON.stringify(settings || {}),
2619
+ // Where each leaf came from (JSON pointer → { kind, at }), as lifecycle hooks get it.
2620
+ OATS_SETTINGS_ORIGINS: JSON.stringify(origins || {}),
2441
2621
  // PATH is not a trusted runtime boundary (maintainer finding 1): pass the
2442
2622
  // canonical absolute executable of THIS CLI; official consumers execFile
2443
2623
  // it directly and never resolve `oats` from PATH or a shell.
@@ -2507,7 +2687,7 @@ function versionCmd() {
2507
2687
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2508
2688
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2509
2689
  // never listed.
2510
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "teams", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2690
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "teams", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2511
2691
  return;
2512
2692
  }
2513
2693
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2921,8 +3101,9 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
2921
3101
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
2922
3102
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
2923
3103
  else if (cmd === "session") await sessionCmd();
2924
- else if (cmd === "schedule") scheduleCmd();
3104
+ else if (cmd === "schedule") await scheduleCmd();
2925
3105
  else if (cmd === "trigger") await triggerCmd();
3106
+ else if (cmd === "automations") await automationsCmd();
2926
3107
  else if (cmd === "spawn") { try { await spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
2927
3108
  else if (cmd === "retire") retireCmd();
2928
3109
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -3011,7 +3192,7 @@ Usage:
3011
3192
  next steps (clone members you work IN, spawn
3012
3193
  oats-operator-expert); creates no soul, spawns nothing
3013
3194
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3014
- oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3195
+ oats schedule list|show <id>|test <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3015
3196
  enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
3016
3197
  tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
3017
3198
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
@@ -3020,6 +3201,13 @@ Usage:
3020
3201
  | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3021
3202
  polled with the host's gh by the schedule tick;
3022
3203
  see docs/schedules.md#triggers)
3204
+ oats trigger|schedule add … --workspace <member> --runs-on <host> --owner <host>/<login>
3205
+ a trigger/schedule declared in Git (<member>/<id>):
3206
+ writes oats-triggers|oats-schedules/<id>.yaml in a
3207
+ checkout of the member, else prints it
3208
+ oats automations refresh [--json] re-discover the workspace triggers and schedules
3209
+ (the snapshot the host tick reads; oats sync does
3210
+ it too)
3023
3211
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3024
3212
  bound to the new instance's home (docs/schedules.md)
3025
3213
  oats session upload --file <path> store a copy of a local file as a private attachment
@@ -3053,6 +3241,9 @@ Usage:
3053
3241
  [--name <slug>] the exact instance name (no <agent>- prefix;
3054
3242
  not with --purpose); must be a slug, not a
3055
3243
  soul name, and unused in the deployment
3244
+ [--provider <capability> <key>=<value>] a provider setting for this spawn only
3245
+ (repeatable; dotted keys nest; recorded in
3246
+ instance.json providers.<capability>)
3056
3247
  oats retire <instance> [--force] retire an instance (window, hooks,
3057
3248
  [--self] [--delete-branch] worktree, home); --self = retire the
3058
3249
  [--keep-dir] [--json] CALLING instance: the window dies, then