@awebai/oats 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/oats.mjs +296 -106
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
  3. package/capabilities/oats-okf/injects/okf.md +33 -33
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +2 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +1 -5
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +28 -3
  11. package/capabilities/oats-okf/lib/stores.mjs +9 -4
  12. package/capabilities/oats-okf/lib/worker.mjs +82 -8
  13. package/capabilities/oats-okf/oats.json +14 -8
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
  16. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  17. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  18. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  19. package/capabilities/oats-okf-harvest/oats.json +26 -0
  20. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  21. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  22. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
  23. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  24. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  25. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  26. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  27. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  28. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  29. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  30. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  32. package/capabilities/oats-review/injects/review.md +3 -2
  33. package/capabilities/oats-review/oats.json +3 -4
  34. package/docs/capabilities.md +41 -9
  35. package/docs/capability-manifest.schema.json +0 -7
  36. package/docs/desktop-cli-api.md +257 -11
  37. package/docs/implementation.md +1 -1
  38. package/docs/knowledge-capability-authoring.md +8 -2
  39. package/docs/knowledge-reference/package-craft.md +8 -5
  40. package/docs/knowledge.md +101 -0
  41. package/docs/oats-local.schema.json +31 -1
  42. package/docs/official-catalog.md +7 -4
  43. package/docs/packages.md +11 -5
  44. package/docs/release-lane.md +1 -1
  45. package/docs/release-notes/v0.29.0.md +240 -0
  46. package/docs/schedules.md +133 -5
  47. package/docs/souls-and-instances.md +4 -6
  48. package/docs/workspaces.md +11 -2
  49. package/lib/automations.mjs +369 -0
  50. package/lib/core.mjs +65 -154
  51. package/lib/instance-inspect.mjs +12 -4
  52. package/lib/instance-resolution.mjs +31 -182
  53. package/lib/materialize.mjs +5 -7
  54. package/lib/operator-dispatch.mjs +1 -2
  55. package/lib/packages.mjs +17 -0
  56. package/lib/remote.mjs +21 -1
  57. package/lib/resolve.mjs +51 -7
  58. package/lib/schedule.mjs +211 -41
  59. package/lib/triggers.mjs +182 -49
  60. package/lib/workspace.mjs +1 -1
  61. package/package-catalog.json +6 -4
  62. package/package.json +1 -1
  63. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  64. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  65. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  66. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  67. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  68. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/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. */
@@ -1072,6 +1073,10 @@ const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.repla
1072
1073
  const teamLabel = (team) => team ?? "unassigned";
1073
1074
  const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1074
1075
 
1076
+ /** A soul's spawn default harness and model (feature desktop-facts): its definition's, else the kernel's (pi, the
1077
+ * harness's native model) — what `oats spawn <soul>` takes with no --harness/--model/--launch-config. */
1078
+ const soulDefaults = (def) => ({ harness: def?.harness ?? "pi", model: def?.model ?? null, harnessFrom: def?.harness ? "soul" : "kernel-default" });
1079
+
1075
1080
  /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1076
1081
  * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1077
1082
  * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE). */
@@ -1080,15 +1085,15 @@ function workspaceItems(discovery, lock) {
1080
1085
  const capabilities = [];
1081
1086
  for (const m of discovery.members) {
1082
1087
  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 });
1088
+ 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
1089
  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
1090
  }
1086
1091
  for (const ext of discovery.external || []) {
1087
1092
  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 });
1093
+ 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
1094
  }
1090
1095
  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 });
1096
+ 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
1097
  }
1093
1098
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1094
1099
  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 +1169,17 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1164
1169
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
1165
1170
  // oats-local.yaml + sync is a complete deployment; spawn must not answer E_NO_DEPLOYMENT after it.
1166
1171
  try { mkdirSync(join(ctx.deploymentDir, "agents"), { recursive: true }); } catch { /* reported by spawn's E_NO_DEPLOYMENT remedy if it matters */ }
1172
+ // Workspace automations (0.29.0): discovered with the members and the new lock (a trigger
1173
+ // template instantiates at the locked package commit), kept as the snapshot the host tick reads.
1174
+ let automations;
1175
+ try { automations = await takeAutomationSnapshot(ctx, discovery, lock); }
1176
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1177
+ problems.push(...automations.problems);
1167
1178
  const members = memberRows(discovery);
1168
1179
  const packages = packageRows(lock);
1169
1180
  const changes = resolved.changes;
1170
1181
  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 ?? [] };
1182
+ 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
1183
  return { report, lock, discovery, items, lockFile, problems };
1173
1184
  }
1174
1185
 
@@ -1207,11 +1218,91 @@ function printSyncReport(ctx, synced) {
1207
1218
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
1208
1219
  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
1220
  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)"}`);
1221
+ const ac = report.automations;
1222
+ 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
1223
  for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
1211
1224
  for (const w of discovery.warnings ?? []) console.log(`warning ${w.code} ${w.message}`);
1212
1225
  console.log(`\nlock ${shortPath(lockFile)}`);
1213
1226
  }
1214
1227
 
1228
+ /** Discover the workspace automations of a discovery and write the snapshot the host tick reads
1229
+ * (lib/automations.mjs). A trigger's `from:` instantiates the package template at the lock's
1230
+ * commit; a schedule is checked against this deployment. → the snapshot */
1231
+ async function takeAutomationSnapshot(ctx, discovery, lock) {
1232
+ const kinds = await automationKinds(ctx.deploymentDir, lock, ctx.remoteOptions);
1233
+ const found = await A.discoverAutomations(discovery, { remote: kinds.remote, memberName: memberLabel, kinds: [kinds.trigger, kinds.schedule], previous: A.readSnapshot(ctx.deploymentDir) });
1234
+ const snap = A.snapshotOf(discovery, found, { souls: A.soulIndexOf(discovery, memberLabel) });
1235
+ // A workspace with no triggers or schedules leaves nothing behind (onboard creates what the
1236
+ // kernel needs and nothing else); a snapshot that existed is replaced, so a removal lands.
1237
+ const empty = !snap.triggers.length && !snap.schedules.length && !snap.problems.length;
1238
+ if (!empty || A.readSnapshot(ctx.deploymentDir)) A.writeSnapshot(ctx.deploymentDir, snap);
1239
+ return snap;
1240
+ }
1241
+ /** The two kinds' descriptors (lib/triggers.mjs triggerKind, lib/schedule.mjs scheduleKind). */
1242
+ async function automationKinds(dep, lock, remoteOptions) {
1243
+ const T = await import("../lib/triggers.mjs");
1244
+ const { bindRemote } = await import("../lib/packages.mjs");
1245
+ const remote = bindRemote(remoteModule, remoteOptions);
1246
+ return { remote, trigger: T.triggerKind({ lock, remote }), schedule: scheduleKind({ dep }) };
1247
+ }
1248
+ const automationCounts = (snap) => ({ triggers: snap.triggers.length, schedules: snap.schedules.length, problems: snap.problems.length, takenAt: snap.takenAt });
1249
+
1250
+ /** `oats automations refresh [--dir] [--json]`: re-discover the workspace automations and rewrite
1251
+ * the snapshot (what `oats sync` does for them, without touching the lock). The host tick runs it
1252
+ * when the snapshot is ten minutes old. */
1253
+ async function automationsCmd() {
1254
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1255
+ if (args[1] !== "refresh") return bail("E_USAGE", "usage: oats automations refresh [--dir <deployment>] [--json]");
1256
+ const ctx = workspaceContext(bail);
1257
+ const discovery = await discoverForCli(ctx, bail);
1258
+ let lock;
1259
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1260
+ let snap;
1261
+ try { snap = await takeAutomationSnapshot(ctx, discovery, lock); }
1262
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1263
+ const result = { automationsApi: A.AUTOMATIONS_API, snapshot: A.snapshotPath(ctx.deploymentDir), ...automationCounts(snap), problems: snap.problems };
1264
+ if (JSON_MODE) return jsonOk(result);
1265
+ console.log(`automations ${result.triggers} trigger(s), ${result.schedules} schedule(s) — snapshot ${shortPath(result.snapshot)}`);
1266
+ for (const p of snap.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
1267
+ }
1268
+
1269
+ /** `oats trigger|schedule add --workspace <member> --runs-on <host> --owner <host>/<login>`: the
1270
+ * workspace file, written into the member's checkout when `--dir` (or the cwd) is inside one, else
1271
+ * printed. What is written must read back as the same automation (parseAutomationFile + expand). */
1272
+ async function addWorkspaceAutomation(desc, { id, body }) {
1273
+ const bail = (code, msg, details) => { throw Object.assign(new Error(msg), { code, details }); };
1274
+ const member = flag("workspace"), runsOn = flag("runs-on"), owner = flag("owner"), description = flag("description");
1275
+ if (typeof member !== "string" || !member) bail("E_BAD_ARGS", "--workspace needs a member name");
1276
+ 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");
1277
+ if (!A.parseOwner(owner)) bail("E_BAD_ARGS", "--owner <host>/<login>: the GitHub account it acts as, e.g. github.com/acme-kb-bot");
1278
+ const ctx = workspaceContext(bail);
1279
+ const discovery = await discoverForCli(ctx, bail);
1280
+ const row = (discovery.members || []).find((m) => m.confirmed && (memberLabel(m.key) === member || m.key === member));
1281
+ 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 });
1282
+ const name = memberLabel(row.key);
1283
+ const content = A.automationFileText(desc, { id, description: typeof description === "string" ? description : undefined, runsOn, owner, body });
1284
+ const parsed = A.parseAutomationFile(desc, { stem: id, path: `${desc.folder}/${id}.yaml`, bytes: Buffer.from(content), member: name, repoKey: row.key, commit: row.commit });
1285
+ if (parsed.problem) bail(parsed.problem.code, parsed.problem.message, { path: parsed.problem.path });
1286
+ await desc.expand(parsed.entry);
1287
+ const sameRepo = (url) => { try { return remoteModule.parseRepoRef(url).key === row.key; } catch { return false; } };
1288
+ const at = A.memberCheckoutFor(dirFlag(), desc, id, { sameRepo });
1289
+ const file = { member: name, repoKey: row.key, path: `${desc.folder}/${id}.yaml`, content };
1290
+ if (!at) return { file, written: false, id: `${name}/${id}` };
1291
+ 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 });
1292
+ mkdirSync(dirname(at.file), { recursive: true });
1293
+ writeFileSync(at.file, content);
1294
+ return { file: { ...file, written: at.file }, written: true, id: `${name}/${id}` };
1295
+ }
1296
+ const printWorkspaceAdd = (r) => r.written
1297
+ ? `wrote ${shortPath(r.file.written)} — commit and push it; after \`oats sync\` (or within ten minutes) ${r.id} runs on its host`
1298
+ : `# ${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}`;
1299
+ /** Enable/disable a WORKSPACE trigger or schedule on this host: oats-local.yaml
1300
+ * `triggers.disabled` / `schedules.disabled`. */
1301
+ function setDisabledHereCli(ws, kind, qid, enabled) {
1302
+ const { path } = loadLocal(ws);
1303
+ return A.setDisabledHere(path, kind, qid, !enabled, { validate: validateLocal });
1304
+ }
1305
+
1215
1306
  /** `oats sync [--dir] [--json]` — contract §6. */
1216
1307
  async function syncCmd() {
1217
1308
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1325,6 +1416,10 @@ async function workspaceCmd() {
1325
1416
  const unsynced = declared.filter((id) => !locked.has(id));
1326
1417
  const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
1327
1418
  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 ?? [] };
1419
+ // Workspace automations (0.29.0), from the snapshot `oats sync` took, placed on this host.
1420
+ const actx = scopeAutomations(ctx.deploymentDir, {});
1421
+ 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 } : {}) })) };
1422
+ await workspaceStatusFacts(result, discovery, lock, ctx);
1328
1423
  if (JSON_MODE) { jsonOk(result); return; }
1329
1424
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1330
1425
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
@@ -1337,10 +1432,47 @@ async function workspaceCmd() {
1337
1432
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
1338
1433
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
1339
1434
  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(" ")}`);
1435
+ if (result.automations.rows.length) {
1436
+ console.log(`\nAutomations (this host: ${result.automations.host ?? "unnamed — set host.name in oats-local.yaml"}):`);
1437
+ 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"]));
1438
+ }
1340
1439
  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
1440
  if (discovery.warnings?.length) { console.log("\nWarnings:"); for (const w of discovery.warnings) console.log(` ${w.code} ${w.message}`); }
1342
1441
  }
1343
1442
 
1443
+ /** `oats workspace status` facts (feature desktop-facts): the workspace and team defaults as rows; this
1444
+ * computer (member clones and the rule that found each, disabled souls, the lock); file locations with
1445
+ * browsable urls; each catalog package's newer version, when the official catalog has one. */
1446
+ async function workspaceStatusFacts(result, discovery, lock, ctx) {
1447
+ const { memberCloneOf } = await import("../lib/instance-resolution.mjs");
1448
+ const { browseUrl } = remoteModule;
1449
+ const defaults = discovery.standalone === true ? {} : (discovery.workspace?.defaults ?? {});
1450
+ 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));
1451
+ const slot = (d) => (d && typeof d === "object" ? Object.entries(d).map(([name, v]) => ({ name, from: v?.from ?? null }))[0] ?? null : d ?? null);
1452
+ result.defaults = {
1453
+ slots: Object.fromEntries(LAYERS.map((l) => [l, slot(defaults[l])])),
1454
+ capabilities: rows(defaults.capabilities),
1455
+ byTeam: Object.fromEntries(Object.entries(defaults.byTeam && typeof defaults.byTeam === "object" ? defaults.byTeam : {}).map(([label, t]) => [label, { capabilities: rows(t?.capabilities) }])),
1456
+ };
1457
+ result.workspace.file = discovery.standalone === true ? null : { path: "oats-workspace.yaml", url: browseUrl(discovery.key, discovery.commit, "oats-workspace.yaml") };
1458
+ 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") } });
1459
+ result.clones = result.members.map((m) => {
1460
+ try { return { key: m.key, name: m.name, ...memberCloneOf(ctx.deploymentDir, ctx.local, m.key) }; }
1461
+ 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; }
1462
+ });
1463
+ result.disabledSouls = [...(Array.isArray(ctx.local?.souls?.disabled) ? ctx.local.souls.disabled : [])];
1464
+ result.lock = { path: join(ctx.deploymentDir, LOCK_FILE), lockfileVersion: lock.lockfileVersion ?? null };
1465
+ const { compareSemver } = await import("../lib/servers.mjs");
1466
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { catalog = null; }
1467
+ const cat = catalog && typeof catalog === "object" ? (catalog.packages && typeof catalog.packages === "object" && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
1468
+ for (const p of result.packages) {
1469
+ const ref = p.source === `catalog:${p.id}` ? cat[p.id]?.ref : undefined;
1470
+ const tag = standaloneCatalogVersion(ref);
1471
+ const version = tag ? tag.replace(/^v/, "") : null;
1472
+ p.latest = version && typeof p.version === "string" && compareSemver(version, p.version) > 0 ? { version, ref } : null;
1473
+ }
1474
+ }
1475
+
1344
1476
  /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
1345
1477
  async function itemsCmd(kind) {
1346
1478
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1349,6 +1481,10 @@ async function itemsCmd(kind) {
1349
1481
  let lock;
1350
1482
  try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1351
1483
  const items = workspaceItems(discovery, lock)[kind];
1484
+ // One command's reads at a commit are shared: many souls resolve over the same manifests and listings.
1485
+ const remote = memoizedRemote(remoteModule);
1486
+ if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
1487
+ if (kind === "souls") await soulFacts(items, discovery, lock, ctx, remote);
1352
1488
  const standalone = discovery.standalone === true;
1353
1489
  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
1490
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
@@ -1359,6 +1495,60 @@ async function itemsCmd(kind) {
1359
1495
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
1360
1496
  }
1361
1497
 
1498
+ /** Capability rows' manifest facts (feature desktop-facts): layer, description, and what each provides
1499
+ * (skills, commands, hooks by name) — member rows from discovery's manifest, package rows from the manifest
1500
+ * at the locked commit (the sync cache; no network beyond sync's). An unreadable package leaves its rows' facts null. */
1501
+ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1502
+ const { capabilityProvides, lockedPackageCapabilities, memberRef } = await import("../lib/resolve.mjs");
1503
+ const remoteOptions = ctx.remoteOptions;
1504
+ const catalog = (() => { try { return officialPackageCatalog(); } catch { return null; } })();
1505
+ const packages = new Map();
1506
+ for (const [id, entry] of Object.entries(lock?.packages || {})) {
1507
+ try { packages.set(id, await lockedPackageCapabilities(id, entry, { catalog, remote, remoteOptions })); } catch { packages.set(id, null); }
1508
+ }
1509
+ // A member capability's fingerprint is its Git tree at the commit (one listing per member commit); a
1510
+ // package's is the package integrity (the lock).
1511
+ const trees = new Map();
1512
+ const treeOf = async ({ repoKey, commit }, ref, dir) => {
1513
+ const key = `${repoKey}\0${commit}`;
1514
+ if (!trees.has(key)) {
1515
+ const member = discovery.members.find((m) => m.key === repoKey);
1516
+ 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);
1517
+ trees.set(key, remoteModule.remoteTreeOids(ref, commit, [...new Set(dirs)], remoteOptions).catch(() => new Map()));
1518
+ }
1519
+ return (await trees.get(key)).get(dir) ?? null;
1520
+ };
1521
+ for (const row of rows) {
1522
+ let manifest = null, ref = null, dir = null;
1523
+ if (row.kind === "package") {
1524
+ const read = packages.get(row.package), cap = read?.capabilities.find((c) => c.name === row.name);
1525
+ if (cap) ({ manifest, dir } = cap), ref = read.ref;
1526
+ } else {
1527
+ const cap = discovery.members.find((m) => m.key === row.repoKey)?.capabilities.find((c) => c.name === row.name);
1528
+ if (cap) { manifest = cap.manifest; dir = cap.path; ref = memberRef(discovery, remoteModule, row.repoKey); }
1529
+ }
1530
+ const provides = manifest ? await capabilityProvides({ ref, commit: row.commit, dir, manifest, remote, remoteOptions }) : { skills: null, commands: null, hooks: null };
1531
+ const repoKey = row.kind === "package" && ref ? (() => { try { return remoteModule.parseRepoRef(ref).key; } catch { return null; } })() : row.repoKey;
1532
+ const file = dir ? { path: `${dir}/oats.json`, url: remoteModule.browseUrl(repoKey, row.commit, `${dir}/oats.json`) } : null;
1533
+ const tree = row.kind !== "package" && ref && dir ? await treeOf(row, ref, dir) : null;
1534
+ Object.assign(row, { layer: manifest?.layer ?? row.layer ?? null, description: manifest?.description ?? null, ...provides, file, tree });
1535
+ }
1536
+ }
1537
+
1538
+ /** Souls rows' spawnability (feature desktop-facts): the refusal a spawn of each would meet, computed from
1539
+ * discovery and the lock without spawning (soulSpawnability). */
1540
+ async function soulFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1541
+ const { soulSpawnability } = await import("../lib/instance-resolution.mjs");
1542
+ const entryOf = (row) => row.kind === "package" ? (discovery.packageSouls || []).find((p) => p.qualifiedName === row.qualifiedName)
1543
+ : row.kind === "external" ? (discovery.external || []).map((e) => e.soul).find((x) => x.name === row.name && x.repoKey === row.repoKey)
1544
+ : discovery.members.find((m) => m.key === row.repoKey)?.souls.find((x) => x.name === row.name);
1545
+ for (const row of rows) {
1546
+ const entry = entryOf(row);
1547
+ row.file = { path: `${row.path}/soul.yaml`, url: remoteModule.browseUrl(row.repoKey, row.commit, `${row.path}/soul.yaml`) };
1548
+ 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` } });
1549
+ }
1550
+ }
1551
+
1362
1552
  // ---------- roster: status / spawn / retire / create ----------
1363
1553
  /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
1364
1554
  * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
@@ -1383,7 +1573,7 @@ async function statusDrift(data) {
1383
1573
  for (const a of data) {
1384
1574
  if (!a.dir) continue;
1385
1575
  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) */ }
1576
+ catch { /* not a workspace soul (classic, or an unreadable stamp) */ }
1387
1577
  }
1388
1578
  const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
1389
1579
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
@@ -1546,7 +1736,7 @@ async function spawnCmd() {
1546
1736
  // the per-commit cache, or fetches it to a temporary copy (reported as soulFetched).
1547
1737
  const providerPairs = [];
1548
1738
  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;
1739
+ let wsPrepared, soulFetched = false, wsDiscovery;
1550
1740
  {
1551
1741
  let discovery = null;
1552
1742
  try {
@@ -1581,50 +1771,12 @@ async function spawnCmd() {
1581
1771
  const file = officialCatalogFile();
1582
1772
  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
1773
  }
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);
1774
+ if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
1588
1775
  else throw e;
1589
1776
  }
1590
1777
  }
1591
1778
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
1592
1779
  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
1780
  checkDirectoryOptions(requestedWork || agent?.work);
1629
1781
  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
1782
  for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
@@ -1653,9 +1805,6 @@ async function spawnCmd() {
1653
1805
  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
1806
  // NOTE: explicit "unrelated" is passed through to the kernel.
1655
1807
  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
1808
  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
1809
  }
1661
1810
  const taskText = flag("task");
@@ -1715,10 +1864,10 @@ async function spawnCmd() {
1715
1864
  // convention <deployment>/<member name>. Never an ambient Git checkout around the
1716
1865
  // deployment. Resolved once here so a preview sees exactly what the apply would.
1717
1866
  let preparedRepo;
1718
- if (wsPrepared || capabilityPrepared) {
1867
+ if (wsPrepared) {
1719
1868
  try {
1720
1869
  const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
1721
- prepared = wsPrepared ?? capabilityPrepared;
1870
+ prepared = wsPrepared;
1722
1871
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
1723
1872
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
1724
1873
  prepared.toCapabilityRows = toCapabilityRows;
@@ -1782,7 +1931,7 @@ async function spawnCmd() {
1782
1931
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1783
1932
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1784
1933
  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; }
1934
+ if (e?.code === "E_INSTANCE_NAME_INVALID") { bail(e.code, e.message, e.details); throw e; }
1786
1935
  if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
1787
1936
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
1788
1937
  }
@@ -1935,7 +2084,7 @@ function retireCmd() {
1935
2084
  /** `oats schedule ...`: workspace-scoped definitions, host-owned execution
1936
2085
  * (lib/schedule.mjs). Every subcommand answers the envelope; nothing here
1937
2086
  * launches unless a job is due or run-now is asked. */
1938
- function scheduleCmd() {
2087
+ async function scheduleCmd() {
1939
2088
  const sub = args[1];
1940
2089
  const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
1941
2090
  // One schedule-owning scope for a directory: its deployment (the directory
@@ -1944,7 +2093,11 @@ function scheduleCmd() {
1944
2093
  let scope;
1945
2094
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1946
2095
  const io = { hostStatus: () => hostUnitStatus() };
1947
- const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2096
+ // The workspace automations of this deployment (the snapshot) placed on this host.
2097
+ let actx;
2098
+ const ctx = () => (actx ??= scopeAutomations(ws(), { io }));
2099
+ const isWorkspace = (qid) => A.splitId(qid).scope !== "local";
2100
+ const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
1948
2101
  const readSpec = () => {
1949
2102
  const inline = flag("spec-json");
1950
2103
  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 +2109,41 @@ function scheduleCmd() {
1956
2109
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
1957
2110
  try {
1958
2111
  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) }); }
2112
+ 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; }
2113
+ case "show": return out({ schedule: describeSchedule(ws(), needId(), io, { ctx: ctx() }) });
2114
+ 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)`);
2115
+ case "add": {
2116
+ const spec = readSpec();
2117
+ if (flag("workspace") !== undefined) {
2118
+ const wid = id ?? spec.id;
2119
+ if (typeof wid !== "string") throw scheduleError("E_BAD_ARGS", "oats schedule add <id> --workspace <member> …: the id names the file (oats-schedules/<id>.yaml)");
2120
+ const { id: _i, enabled: _e, kind, ...rest } = spec; void _i; void _e;
2121
+ const r = await addWorkspaceAutomation(scheduleKind({ dep: ws() }), { id: wid, body: { run: kind ?? "spawn", ...rest } });
2122
+ return out(r, printWorkspaceAdd);
2123
+ }
2124
+ if (id && spec.id === undefined) spec.id = id;
2125
+ 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" });
2126
+ return out({ schedule: addSchedule(ws(), spec, io) });
2127
+ }
1962
2128
  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") }));
2129
+ case "enable":
2130
+ case "disable": {
2131
+ const on = sub === "enable";
2132
+ if (!isWorkspace(needId())) return out({ schedule: setScheduleEnabled(ws(), id, on, io) });
2133
+ describeSchedule(ws(), id, io, { ctx: ctx() }); // an unknown id is refused before the file changes
2134
+ setDisabledHereCli(ws(), "schedule", A.qualifiedId(id), on);
2135
+ actx = undefined;
2136
+ return out({ schedule: describeSchedule(ws(), id, io, { ctx: ctx() }) });
2137
+ }
2138
+ case "run": return out(runScheduleNow(ws(), needId(), { io, force: args.includes("--force"), ctx: ctx() }));
1966
2139
  case "remove": return out(removeSchedule(ws(), needId(), { force: args.includes("--force") }));
1967
- case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear") }));
2140
+ case "reconcile": return out(reconcileSchedule(ws(), needId(), { io, clear: args.includes("--clear"), ctx: ctx() }));
1968
2141
  case "tick": {
1969
2142
  const dryRun = args.includes("--dry-run");
1970
2143
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
1971
2144
  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 })]);
2145
+ const tctx = scopeAutomations(ws(), { io, refresh: !dryRun });
2146
+ 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
2147
  return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
1974
2148
  }
1975
2149
  case "host": {
@@ -1979,7 +2153,7 @@ function scheduleCmd() {
1979
2153
  if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
1980
2154
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
1981
2155
  }
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]");
2156
+ 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
2157
  }
1984
2158
  } catch (e) {
1985
2159
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
@@ -1998,16 +2172,28 @@ async function triggerCmd() {
1998
2172
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1999
2173
  const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
2000
2174
  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}` : ""}`;
2175
+ 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>)";
2176
+ 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");
2177
+ 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}` : ""}`;
2178
+ // The workspace automations of this deployment (the snapshot) placed on this host.
2179
+ let actx;
2180
+ const ctx = () => (actx ??= scopeAutomations(ws(), {}));
2181
+ const isWorkspace = (qid) => A.splitId(qid).scope !== "local";
2003
2182
  try {
2004
2183
  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)");
2184
+ 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>)");
2185
+ case "show": return out({ trigger: T.describeTrigger(ws(), needId(), ctx()) });
2186
+ case "enable":
2187
+ case "disable": {
2188
+ const on = sub === "enable";
2189
+ if (!isWorkspace(needId())) return out({ trigger: T.setTriggerEnabled(ws(), id, on, ctx()) }, (r) => line(r.trigger));
2190
+ T.describeTrigger(ws(), id, ctx()); // an unknown id is refused before the file changes
2191
+ setDisabledHereCli(ws(), "trigger", A.qualifiedId(id), on);
2192
+ actx = undefined;
2193
+ return out({ trigger: T.describeTrigger(ws(), id, ctx()) }, (r) => line(r.trigger));
2194
+ }
2195
+ 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(", ")})` : ""}`);
2196
+ 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
2197
  case "test": {
2012
2198
  let workspaceTeams = null;
2013
2199
  try {
@@ -2015,8 +2201,9 @@ async function triggerCmd() {
2015
2201
  const { local } = loadLocal(ws());
2016
2202
  if (typeof local.workspace === "string") workspaceTeams = Object.keys((await observeWorkspace(local.workspace, { remoteOptions: remoteOptionsFromEnv() })).workspace.teams || {});
2017
2203
  } catch { /* reported as unknown (null) */ }
2018
- return out(T.testTrigger(ws(), needId(), { workspaceTeams }), (r) => [
2204
+ return out(T.testTrigger(ws(), needId(), { workspaceTeams, ctx: ctx() }), (r) => [
2019
2205
  `trigger ${r.id}: ${r.ok ? "ready" : "NOT ready"} (nothing was spawned)`,
2206
+ ` 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
2207
  ` gh auth ${r.gh.ok ? `ok — ${r.gh.account ?? "?"} via ${r.gh.credentialSource}` : `FAILED${r.gh.detail ? ` — ${r.gh.detail}` : ""}`}`,
2021
2208
  ` 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
2209
  ` 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 +2230,17 @@ async function triggerCmd() {
2043
2230
  if (Object.keys(sets).length) throw T.triggerError("E_BAD_ARGS", "--set fills a package template's parameters; with --file, edit the file");
2044
2231
  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
2232
  if (idFlag !== undefined) spec.id = idFlag;
2046
- } else spec = await triggerFromPackage(T, String(from), sets, idFlag);
2233
+ }
2234
+ if (flag("workspace") !== undefined) {
2235
+ // A workspace trigger (0.29.0): the file in the member, from/set when made from a template.
2236
+ const kinds = await automationKinds(ws(), readLock(ws()), remoteOptionsFromEnv());
2237
+ const wid = typeof idFlag === "string" ? idFlag : from !== undefined ? String(from).split(":")[1] : spec?.id;
2238
+ if (typeof wid !== "string") throw T.triggerError("E_BAD_ARGS", "--workspace: name the trigger with --id (or an id in the file)");
2239
+ 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);
2240
+ const r = await addWorkspaceAutomation(kinds.trigger, { id: wid, body });
2241
+ return out(r, printWorkspaceAdd);
2242
+ }
2243
+ if (spec === undefined) spec = await triggerFromPackage(T, String(from), sets, idFlag);
2047
2244
  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
2245
  }
2049
2246
  default: throw T.triggerError("E_BAD_ARGS", usage);
@@ -2056,28 +2253,8 @@ async function triggerCmd() {
2056
2253
  /** A package trigger template (`triggers: [{ id, file }]` in the locked package's oats-package.json),
2057
2254
  * read at the lock's commit and instantiated with the --set parameters. */
2058
2255
  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 } });
2256
+ const { bindRemote } = await import("../lib/packages.mjs");
2257
+ return T.packageTriggerTemplate(readLock(scheduleScopeOf(dirFlag())), from, sets, { id: idFlag, remote: bindRemote(remoteModule, remoteOptionsFromEnv()) });
2081
2258
  }
2082
2259
 
2083
2260
  async function sessionCmd() {
@@ -2326,7 +2503,7 @@ async function capabilityCommand() {
2326
2503
  // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2327
2504
  // already fetched exactly this commit; otherwise the command gets none (never ambient).
2328
2505
  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);
2506
+ 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
2507
  }
2331
2508
 
2332
2509
  async function dispatch() {
@@ -2340,14 +2517,14 @@ async function capabilityCommand() {
2340
2517
  // braces: the ids come from instance.json, which spawn wrote from resolved
2341
2518
  // manifests. Null-prototype because the dispatcher indexes it with the
2342
2519
  // namespace the operator typed on the command line.
2343
- let capSettings = Object.create(null);
2520
+ let capSettings = Object.create(null), capOrigins = Object.create(null);
2344
2521
  let deployment = null;
2345
2522
  let soulDir;
2346
2523
  try {
2347
2524
  if (metaFile && existsSync(metaFile)) {
2348
2525
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
2349
2526
  activeIds = (meta.capabilities || []).map((c) => c.id);
2350
- for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
2527
+ for (const c of meta.capabilities || []) { capSettings[c.id] = c.settings || {}; capOrigins[c.id] = c.settingsOrigins || {}; }
2351
2528
  // Workspace-model homes only (lead decision c3 Q2).
2352
2529
  if (isCapturedHome(meta)) { const e = capturedHomeRefusal(instanceHome, "nothing was dispatched"); bail(e.code, e.message, e.details); }
2353
2530
  if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
@@ -2386,14 +2563,14 @@ async function capabilityCommand() {
2386
2563
  const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2387
2564
  teamCtx = homeTeamCtx(live.teams, live.source);
2388
2565
  }
2389
- return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir, soulDir);
2566
+ return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, soulDir);
2390
2567
  }
2391
2568
 
2392
2569
  /** Help / unknown-command / spec validation / exec — shared by every context.
2393
2570
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
2394
2571
  * resolves the directory holding the executable — the operator branch fetches
2395
2572
  * the module tree only when a command is actually going to run). */
2396
- async function runManifestCommand(m, settings, teamCtx, ensureDir, soulDir) {
2573
+ async function runManifestCommand(m, { settings, origins }, teamCtx, ensureDir, soulDir) {
2397
2574
  const sub = args[1];
2398
2575
  const cmds = Object.keys(m.commands);
2399
2576
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -2438,6 +2615,8 @@ async function capabilityCommand() {
2438
2615
  // lifecycle hooks — capabilities read their settings here instead of
2439
2616
  // importing the kernel resolver.
2440
2617
  OATS_SETTINGS: JSON.stringify(settings || {}),
2618
+ // Where each leaf came from (JSON pointer → { kind, at }), as lifecycle hooks get it.
2619
+ OATS_SETTINGS_ORIGINS: JSON.stringify(origins || {}),
2441
2620
  // PATH is not a trusted runtime boundary (maintainer finding 1): pass the
2442
2621
  // canonical absolute executable of THIS CLI; official consumers execFile
2443
2622
  // it directly and never resolve `oats` from PATH or a shell.
@@ -2507,7 +2686,7 @@ function versionCmd() {
2507
2686
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2508
2687
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2509
2688
  // 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 }));
2689
+ 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
2690
  return;
2512
2691
  }
2513
2692
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2921,8 +3100,9 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
2921
3100
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
2922
3101
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
2923
3102
  else if (cmd === "session") await sessionCmd();
2924
- else if (cmd === "schedule") scheduleCmd();
3103
+ else if (cmd === "schedule") await scheduleCmd();
2925
3104
  else if (cmd === "trigger") await triggerCmd();
3105
+ else if (cmd === "automations") await automationsCmd();
2926
3106
  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
3107
  else if (cmd === "retire") retireCmd();
2928
3108
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -3011,7 +3191,7 @@ Usage:
3011
3191
  next steps (clone members you work IN, spawn
3012
3192
  oats-operator-expert); creates no soul, spawns nothing
3013
3193
  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>
3194
+ oats schedule list|show <id>|test <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3015
3195
  enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
3016
3196
  tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
3017
3197
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
@@ -3020,6 +3200,13 @@ Usage:
3020
3200
  | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3021
3201
  polled with the host's gh by the schedule tick;
3022
3202
  see docs/schedules.md#triggers)
3203
+ oats trigger|schedule add … --workspace <member> --runs-on <host> --owner <host>/<login>
3204
+ a trigger/schedule declared in Git (<member>/<id>):
3205
+ writes oats-triggers|oats-schedules/<id>.yaml in a
3206
+ checkout of the member, else prints it
3207
+ oats automations refresh [--json] re-discover the workspace triggers and schedules
3208
+ (the snapshot the host tick reads; oats sync does
3209
+ it too)
3023
3210
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3024
3211
  bound to the new instance's home (docs/schedules.md)
3025
3212
  oats session upload --file <path> store a copy of a local file as a private attachment
@@ -3053,6 +3240,9 @@ Usage:
3053
3240
  [--name <slug>] the exact instance name (no <agent>- prefix;
3054
3241
  not with --purpose); must be a slug, not a
3055
3242
  soul name, and unused in the deployment
3243
+ [--provider <capability> <key>=<value>] a provider setting for this spawn only
3244
+ (repeatable; dotted keys nest; recorded in
3245
+ instance.json providers.<capability>)
3056
3246
  oats retire <instance> [--force] retire an instance (window, hooks,
3057
3247
  [--self] [--delete-branch] worktree, home); --self = retire the
3058
3248
  [--keep-dir] [--json] CALLING instance: the window dies, then