@awebai/oats 0.27.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/bin/oats.mjs CHANGED
@@ -27,18 +27,20 @@ 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
- import { loadLocal, validateWorkspace, validateLocal } from "../lib/workspace.mjs";
35
+ import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings } from "../lib/workspace.mjs";
36
36
  import { parseConfigData } from "../lib/config-data.mjs";
37
37
  import * as remoteModule from "../lib/remote.mjs";
38
38
  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
- 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";
41
+ import { tickTriggers } from "../lib/triggers.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";
42
44
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
43
45
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
44
46
 
@@ -48,10 +50,32 @@ const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli }
48
50
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
49
51
  import { readEvents } from "../lib/instance-events.mjs";
50
52
 
51
- const args = process.argv.slice(2);
53
+ const rawArgs = process.argv.slice(2);
54
+ /** The kernel's switches: a value never rides one (`--yolo=false` must not turn yolo on). */
55
+ const KERNEL_SWITCHES = new Set(["allow-child-spawns", "apply", "check", "clear", "delete-branch", "discard-worktree", "dry-run", "ephemeral", "force", "help", "host", "json", "keep-dir", "keep-env", "no-child-spawns", "no-launch", "no-recursive", "no-yolo", "plan", "policy", "preview", "print", "replace", "self", "verbose", "yes", "yolo"]);
56
+ /** `--flag=value` is `--flag value`: every kernel reader (flag(), valueFlag(), the onboard and
57
+ * routed-command loops) then applies the spaced form's validation to it. `problem` is an empty
58
+ * `--flag=` or a switch given a value. */
59
+ function expandInlineValues(argv) {
60
+ const out = [];
61
+ let problem;
62
+ for (const a of argv) {
63
+ const eq = a.indexOf("=");
64
+ if (!a.startsWith("--") || eq <= 2) { out.push(a); continue; }
65
+ const name = a.slice(2, eq), value = a.slice(eq + 1);
66
+ problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : undefined;
67
+ out.push(`--${name}`, value);
68
+ }
69
+ return { argv: out, problem };
70
+ }
71
+ const { argv: args, problem: argvProblem } = expandInlineValues(rawArgs);
52
72
  let cmd = args[0];
53
73
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
54
- 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"]);
75
+ /** Commands whose argv another parser reads (packages/record and packages/experimental parse process.argv). */
76
+ const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]);
77
+ /** Commands `--server <id>` runs on a registered server. */
78
+ const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"]);
55
79
  const flag = (name) => {
56
80
  const i = args.indexOf(`--${name}`);
57
81
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -154,7 +178,7 @@ function operationalKnowledgeNote(composition, soulName) {
154
178
  * home-relative (`.oats/modules/<cap>/<inject>`), where an instance carries them. */
155
179
  async function doctorComposition(ctx, soulName, ws, bail) {
156
180
  if (!soulName) return undefined;
157
- const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
181
+ const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone, agentDirOf } = await import("../lib/instance-resolution.mjs");
158
182
  const deployment = dirname(ws.local.path);
159
183
  const root = join(deployment, "agents");
160
184
  const remoteOptions = remoteOptionsFromEnv();
@@ -162,11 +186,11 @@ async function doctorComposition(ctx, soulName, ws, bail) {
162
186
  // A bail exits the process without unwinding: the temporary copies are removed at exit too.
163
187
  process.once("exit", () => { for (const c of cleanups) { try { c(); } catch { /* best effort */ } } });
164
188
  try {
165
- const discovery = await discoverOrStandalone(loadLocal(deployment).local, { remoteOptions });
189
+ const discovery = await discoverOrStandalone(loadLocal(deployment).local, { deployment, remoteOptions });
166
190
  const prepared = await prepareInstance(deployment, soulName, { remoteOptions, discovery });
167
191
  const pv = await previewWorkspaceSoul(prepared, root);
168
192
  cleanups.push(pv.cleanup);
169
- const agent = findAgentAt(root, prepared.soulEntry.name, pv.soulDir);
193
+ const agent = findAgentAt(root, agentDirOf(prepared.soulEntry), pv.soulDir);
170
194
  if (!agent) bail("E_SOUL_UNKNOWN", `soul "${soulName}" was fetched but is not readable as a soul`);
171
195
  const composition = composeInstanceAgentsMd(pv.soulDir, deployment, agent.name, agent.work || "checkout", agent.kind, prepared);
172
196
  const scratch = realpathSync(mkdtempSync(join(tmpdir(), "oats-doctor-home-")));
@@ -1035,7 +1059,7 @@ function standaloneCatalogVersion(ref) {
1035
1059
  async function discoverForCli(ctx, bail) {
1036
1060
  // The standalone case (decisions 10/25) is a discovery too: the repo's own view
1037
1061
  // plus the kernel's oats.core default — discoverOrStandalone decides.
1038
- try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { remoteOptions: ctx.remoteOptions }); }
1062
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions }); }
1039
1063
  catch (e) {
1040
1064
  if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1041
1065
  throw e;
@@ -1049,6 +1073,10 @@ const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.repla
1049
1073
  const teamLabel = (team) => team ?? "unassigned";
1050
1074
  const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1051
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
+
1052
1080
  /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1053
1081
  * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1054
1082
  * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE). */
@@ -1057,12 +1085,15 @@ function workspaceItems(discovery, lock) {
1057
1085
  const capabilities = [];
1058
1086
  for (const m of discovery.members) {
1059
1087
  if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
1060
- 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) });
1061
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 });
1062
1090
  }
1063
1091
  for (const ext of discovery.external || []) {
1064
1092
  const s = ext.soul;
1065
- 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) });
1094
+ }
1095
+ for (const s of discovery.packageSouls || []) {
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) });
1066
1097
  }
1067
1098
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1068
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 });
@@ -1083,7 +1114,7 @@ function memberRows(discovery) {
1083
1114
 
1084
1115
  /** Package rows for `sync` / `workspace status` from the lock. */
1085
1116
  function packageRows(lock) {
1086
- return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities }));
1117
+ return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, souls: (p.souls ?? []).map((x) => x.name) }));
1087
1118
  }
1088
1119
 
1089
1120
  /** Print a padded table: rows are arrays of strings. */
@@ -1124,14 +1155,31 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1124
1155
  const lock = resolved.lock;
1125
1156
  let lockFile;
1126
1157
  try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1158
+ // Package souls come from the lock: list what THIS sync locked, not what the previous lock held.
1159
+ if (discovery.standalone !== true) {
1160
+ let pkg;
1161
+ try { pkg = await discoverPackageSouls(discovery.workspace, lock, { remoteOptions: ctx.remoteOptions }); }
1162
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1163
+ const kept = (list) => list.filter((p) => typeof p.package !== "string");
1164
+ discovery.packageSouls = pkg.souls;
1165
+ discovery.problems = [...kept(discovery.problems), ...pkg.problems];
1166
+ discovery.warnings = workspaceWarnings(discovery.workspace, discovery.members, discovery.external || [], pkg.souls);
1167
+ problems.splice(0, problems.length, ...kept(problems), ...pkg.problems);
1168
+ }
1127
1169
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
1128
1170
  // oats-local.yaml + sync is a complete deployment; spawn must not answer E_NO_DEPLOYMENT after it.
1129
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);
1130
1178
  const members = memberRows(discovery);
1131
1179
  const packages = packageRows(lock);
1132
1180
  const changes = resolved.changes;
1133
1181
  const items = workspaceItems(discovery, lock);
1134
- 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 ?? [] };
1135
1183
  return { report, lock, discovery, items, lockFile, problems };
1136
1184
  }
1137
1185
 
@@ -1152,6 +1200,7 @@ function printSyncReport(ctx, synced) {
1152
1200
  const { report, discovery, items, lockFile } = synced;
1153
1201
  const { members, packages, changes } = report;
1154
1202
  const disabled = new Set(ctx.local.souls?.disabled || []);
1203
+ const isDisabled = (s) => disabled.has(s.name) || disabled.has(s.qualifiedName ?? `${memberLabel(s.repoKey)}/${s.name}`);
1155
1204
  console.log(`workspace ${discovery.workspace?.name ?? `(${standaloneNote(discovery)})`} (${discovery.key} @ ${short(discovery.commit)})`);
1156
1205
  console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
1157
1206
  console.log(`packages ${packages.map((p) => `${p.id} ${p.version} ✓ (@ ${short(p.commit)})`).join(" ") || "(none)"}`);
@@ -1162,17 +1211,98 @@ function printSyncReport(ctx, synced) {
1162
1211
  const externalSouls = items.souls.filter((s) => s.kind === "external");
1163
1212
  // Souls have no private mode (0.26.0); the private count is of repo-owned member capabilities.
1164
1213
  const repoOwned = items.capabilities.filter((c) => c.kind === "member" && c.private);
1165
- const disabledHere = items.souls.filter((s) => disabled.has(s.name));
1166
- console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${disabledHere.length} disabled here) · ${repoOwned.length} private capabilit${repoOwned.length === 1 ? "y" : "ies"}${repoOwned.length ? ` (${repoOwned.map((c) => `${c.name}, ${memberLabel(c.repoKey)} only`).join("; ")})` : ""}`);
1214
+ const packageSouls = items.souls.filter((s) => s.kind === "package");
1215
+ const disabledHere = items.souls.filter(isDisabled);
1216
+ console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${packageSouls.length ? `${packageSouls.length} package, ` : ""}${disabledHere.length} disabled here) · ${repoOwned.length} private capabilit${repoOwned.length === 1 ? "y" : "ies"}${repoOwned.length ? ` (${repoOwned.map((c) => `${c.name}, ${memberLabel(c.repoKey)} only`).join("; ")})` : ""}`);
1167
1217
  const teams = new Map();
1168
1218
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
1169
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); }
1170
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)`);
1171
1223
  for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
1172
1224
  for (const w of discovery.warnings ?? []) console.log(`warning ${w.code} ${w.message}`);
1173
1225
  console.log(`\nlock ${shortPath(lockFile)}`);
1174
1226
  }
1175
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
+
1176
1306
  /** `oats sync [--dir] [--json]` — contract §6. */
1177
1307
  async function syncCmd() {
1178
1308
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1286,6 +1416,10 @@ async function workspaceCmd() {
1286
1416
  const unsynced = declared.filter((id) => !locked.has(id));
1287
1417
  const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
1288
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);
1289
1423
  if (JSON_MODE) { jsonOk(result); return; }
1290
1424
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1291
1425
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
@@ -1294,14 +1428,51 @@ async function workspaceCmd() {
1294
1428
  for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
1295
1429
  console.log("\nPackages:");
1296
1430
  if (!packages.length) console.log(unsynced.length ? ` (none locked yet — \`oats sync\` resolves ${unsynced.join(", ")})` : " (none)");
1297
- else printTable(["package", "version", "source", "commit", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.capabilities.join(",")]));
1431
+ else printTable(["package", "version", "source", "commit", "capabilities", "souls"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.capabilities.join(","), p.souls.join(",") || "—"]));
1298
1432
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
1299
1433
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
1300
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
+ }
1301
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}`); }
1302
1440
  if (discovery.warnings?.length) { console.log("\nWarnings:"); for (const w of discovery.warnings) console.log(` ${w.code} ${w.message}`); }
1303
1441
  }
1304
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
+
1305
1476
  /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
1306
1477
  async function itemsCmd(kind) {
1307
1478
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -1310,6 +1481,10 @@ async function itemsCmd(kind) {
1310
1481
  let lock;
1311
1482
  try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1312
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);
1313
1488
  const standalone = discovery.standalone === true;
1314
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; }
1315
1490
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
@@ -1320,6 +1495,60 @@ async function itemsCmd(kind) {
1320
1495
  if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
1321
1496
  }
1322
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
+
1323
1552
  // ---------- roster: status / spawn / retire / create ----------
1324
1553
  /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
1325
1554
  * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
@@ -1343,8 +1572,8 @@ async function statusDrift(data) {
1343
1572
  const souls = new Map();
1344
1573
  for (const a of data) {
1345
1574
  if (!a.dir) continue;
1346
- 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 }); }
1347
- catch { /* not a workspace soul (classic, capability agent, or unreadable stamp) */ }
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 } : {}) }); }
1576
+ catch { /* not a workspace soul (classic, or an unreadable stamp) */ }
1348
1577
  }
1349
1578
  const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
1350
1579
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
@@ -1354,7 +1583,7 @@ async function statusDrift(data) {
1354
1583
  let discovery;
1355
1584
  // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
1356
1585
  // instance is computed against its member's current state, not reported "unreachable".
1357
- try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
1586
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { lock, remoteOptions: remoteOptionsFromEnv() }); }
1358
1587
  catch (e) {
1359
1588
  const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
1360
1589
  return { drift: new Map(), soul: new Map(), souls, unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
@@ -1369,6 +1598,7 @@ async function statusDrift(data) {
1369
1598
  }
1370
1599
  // The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
1371
1600
  for (const [, stamp] of souls) {
1601
+ if (typeof stamp.package === "string") { const now = (discovery.packageSouls || []).find((p) => p.package === stamp.package && p.path === stamp.path); if (now) stamp.current = now.commit; continue; }
1372
1602
  const member = discovery.members.find((m) => m && m.key === stamp.repoKey);
1373
1603
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1374
1604
  }
@@ -1385,6 +1615,12 @@ function driftLine(row) {
1385
1615
  }
1386
1616
  /** The `soul:` line of an instance — decision 17 for the soul source. */
1387
1617
  function soulDriftLine(row, agentName) {
1618
+ if (row.package) {
1619
+ const base = `soul: ${row.name ?? agentName} from package ${row.package} v${row.version} @ ${short7(row.commit)}`;
1620
+ if (row.status === "moved") return `${base} [package moved since (now v${row.current?.version} @ ${short7(row.current?.commit)})]`;
1621
+ if (row.status === "missing") return `${base} [${row.reason === "soul-absent" ? "soul no longer shipped by the package" : "package no longer locked"}]`;
1622
+ return base;
1623
+ }
1388
1624
  const base = `soul: ${row.name ?? agentName} from ${memberLabel(row.repoKey)} @ ${short7(row.commit)}`;
1389
1625
  if (row.status === "moved") return `${base} [member moved since (now @ ${short7(row.current?.commit)})]`;
1390
1626
  if (row.status === "missing") return `${base} [${row.reason === "soul-absent" ? "soul no longer present" : `member ${row.reason || "unconfirmed"}`}]`;
@@ -1394,6 +1630,7 @@ function soulDriftLine(row, agentName) {
1394
1630
  function soulRepoLabel(a, ws) {
1395
1631
  const stamp = ws?.souls?.get(a.name);
1396
1632
  if (!stamp) return a.repo || "?";
1633
+ if (typeof stamp.package === "string") return `package ${stamp.package} v${stamp.version} @ ${short7(stamp.commit)}`;
1397
1634
  const moved = stamp.current && stamp.commit && stamp.current !== stamp.commit ? ` (member now @ ${short7(stamp.current)})` : "";
1398
1635
  return `${memberLabel(stamp.repoKey)} @ ${short7(stamp.commit)}${moved}`;
1399
1636
  }
@@ -1417,7 +1654,7 @@ async function status() {
1417
1654
  const rows = ws.drift.get(key);
1418
1655
  if (rows) i.modules = rows.map((r) => ({ name: r.module, from: r.from, commit: r.recorded?.commit ?? null, current: r.current, status: r.status, ...(r.reason ? { reason: r.reason } : {}) }));
1419
1656
  const s = ws.soul.get(key);
1420
- if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}) };
1657
+ if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}), ...(s.package ? { package: s.package, version: s.version, currentVersion: s.current?.version ?? null } : {}) };
1421
1658
  }
1422
1659
  }
1423
1660
  console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
@@ -1499,16 +1736,16 @@ async function spawnCmd() {
1499
1736
  // the per-commit cache, or fetches it to a temporary copy (reported as soulFetched).
1500
1737
  const providerPairs = [];
1501
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; }
1502
- let wsPrepared, soulFetched = false, wsSoulUnknown = null, wsDiscovery;
1739
+ let wsPrepared, soulFetched = false, wsDiscovery;
1503
1740
  {
1504
1741
  let discovery = null;
1505
1742
  try {
1506
- const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1743
+ const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone, agentDirOf } = await import("../lib/instance-resolution.mjs");
1507
1744
  const remoteOptions = remoteOptionsFromEnv();
1508
- const { local } = loadLocal(dirFlag());
1509
- discovery = wsDiscovery = await discoverOrStandalone(local, { remoteOptions });
1745
+ const { local, path: localPath } = loadLocal(dirFlag());
1746
+ discovery = wsDiscovery = await discoverOrStandalone(local, { deployment: dirname(localPath), remoteOptions });
1510
1747
  wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
1511
- const soulName = wsPrepared.soulEntry.name;
1748
+ const soulName = agentDirOf(wsPrepared.soulEntry);
1512
1749
  let soulDir;
1513
1750
  if (isPreview) {
1514
1751
  // A preview writes nothing in the deployment: the soul comes from the
@@ -1534,50 +1771,12 @@ async function spawnCmd() {
1534
1771
  const file = officialCatalogFile();
1535
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 });
1536
1773
  }
1537
- // Not a workspace soul: a capability-defined agent (a module's `agents:`
1538
- // soul, resolved below from a materialized copy) may still answer to this name.
1539
- if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
1540
- 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);
1541
1775
  else throw e;
1542
1776
  }
1543
1777
  }
1544
1778
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
1545
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"})`);
1546
- // Capability agent (lead decision c3 Q1): a prepared spawn of its providing module only.
1547
- let capabilityPrepared, capabilityPkg = null;
1548
- if (!agent) {
1549
- // Workspace model: the agent is declared by a capability some INSTANCE already
1550
- // materialized (the --parent home first, then any home under this root) —
1551
- // OKF's memory-harvest worker spawned by a knowledge source, for example.
1552
- const anchorName = flag("parent") || flag("relative-to");
1553
- const anchorHome = anchorName ? (findInstanceHome(root, String(anchorName)) ?? null) : null;
1554
- let modAgent;
1555
- try { modAgent = findModuleCapabilityAgent(root, name, { anchorHome }); }
1556
- catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
1557
- if (modAgent) {
1558
- agent = modAgent;
1559
- note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home under ${shortPath(join(root, name, "instances"))})`);
1560
- } else {
1561
- // No instance carries it: resolve from the deployment's LOCK — a locked
1562
- // package whose capability declares agents/<name> is fetched into the
1563
- // deployment's module store and read from there.
1564
- try {
1565
- const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1566
- const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), discovery: wsDiscovery, catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1567
- if (hit) {
1568
- agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
1569
- 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"))})`); }
1570
- }
1571
- } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
1572
- }
1573
- if (agent) {
1574
- try {
1575
- const { prepareCapabilityAgent } = await import("../lib/instance-resolution.mjs");
1576
- capabilityPrepared = await prepareCapabilityAgent(dirFlag(), agent, { discovery: wsDiscovery, pkg: capabilityPkg, remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
1577
- } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
1578
- }
1579
- }
1580
- if (!agent && wsSoulUnknown) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
1581
1780
  checkDirectoryOptions(requestedWork || agent?.work);
1582
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\``);
1583
1782
  for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
@@ -1606,9 +1805,6 @@ async function spawnCmd() {
1606
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");
1607
1806
  // NOTE: explicit "unrelated" is passed through to the kernel.
1608
1807
  if (relativeTo && relation !== "unrelated") {
1609
- // findInstanceHome also sees capability-defined agents' instance homes
1610
- // (<root>/<name>/ without a soul) — e.g. a reviewer passing
1611
- // --parent "$OATS_INSTANCE" from a capability agent.
1612
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`);
1613
1809
  }
1614
1810
  const taskText = flag("task");
@@ -1616,6 +1812,17 @@ async function spawnCmd() {
1616
1812
  const taskFileFlag = flag("task-file");
1617
1813
  if (taskFileFlag === true) bail("E_BAD_ARGS", "--task-file needs a path");
1618
1814
  if (taskFileFlag && !existsSync(taskFileFlag)) bail("E_BAD_ARGS", `--task-file not found: ${taskFileFlag}`);
1815
+ // --trigger-event <file>: the event a trigger spawns this instance for (lib/triggers.mjs). It is
1816
+ // copied to <home>/.oats/trigger-event.json (OATS_TRIGGER_EVENT_FILE) and recorded as instance.json.trigger.
1817
+ let triggerEvent;
1818
+ { const f = flag("trigger-event");
1819
+ if (f === true) bail("E_BAD_ARGS", "--trigger-event needs a path");
1820
+ if (f !== undefined) {
1821
+ if (!existsSync(f)) bail("E_BAD_ARGS", `--trigger-event not found: ${f}`);
1822
+ try { triggerEvent = JSON.parse(readFileSync(f, "utf8")); } catch (e) { bail("E_BAD_ARGS", `--trigger-event is not valid JSON: ${e.message}`); }
1823
+ const ok = triggerEvent && typeof triggerEvent === "object" && !Array.isArray(triggerEvent) && typeof triggerEvent.trigger === "string" && typeof triggerEvent.source === "string" && typeof triggerEvent.repo === "string" && Number.isInteger(triggerEvent.number) && typeof triggerEvent.event === "string";
1824
+ if (!ok) bail("E_BAD_ARGS", "--trigger-event must hold { trigger, source, repo, number, url, event, headSha, labels, observedAt }");
1825
+ } }
1619
1826
  const relativeRoot = flag("relative-root");
1620
1827
  if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
1621
1828
  if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
@@ -1657,10 +1864,10 @@ async function spawnCmd() {
1657
1864
  // convention <deployment>/<member name>. Never an ambient Git checkout around the
1658
1865
  // deployment. Resolved once here so a preview sees exactly what the apply would.
1659
1866
  let preparedRepo;
1660
- if (wsPrepared || capabilityPrepared) {
1867
+ if (wsPrepared) {
1661
1868
  try {
1662
1869
  const { toCapabilityRows, modulesPreview, requireMemberClone } = await import("../lib/instance-resolution.mjs");
1663
- prepared = wsPrepared ?? capabilityPrepared;
1870
+ prepared = wsPrepared;
1664
1871
  prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
1665
1872
  prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
1666
1873
  prepared.toCapabilityRows = toCapabilityRows;
@@ -1684,6 +1891,7 @@ async function spawnCmd() {
1684
1891
  work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
1685
1892
  launchConfig: valueFlag("launch-config"),
1686
1893
  launch: !args.includes("--no-launch"),
1894
+ ...(triggerEvent ? { triggerEvent } : {}),
1687
1895
  // K6: --preview decides everything and touches nothing; --base <ref>
1688
1896
  // selects a worktree's start point; --model @native-default is the
1689
1897
  // explicit "harness's own default" (distinct from omitting --model).
@@ -1723,7 +1931,7 @@ async function spawnCmd() {
1723
1931
  if (e?.code === "E_IDEMPOTENCY_CONFLICT") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1724
1932
  if (e?.code === "E_PLACEMENT_TAKEN") { bail(e.code, e.message, { instance: e.instance, home: e.home }); throw e; }
1725
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; }
1726
- 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; }
1727
1935
  if (e?.code === "E_SPAWN_INCOMPLETE") { bail(e.code, e.message, { instance: e.instance, home: e.home, launched: e.launched }); throw e; }
1728
1936
  bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
1729
1937
  }
@@ -1876,7 +2084,7 @@ function retireCmd() {
1876
2084
  /** `oats schedule ...`: workspace-scoped definitions, host-owned execution
1877
2085
  * (lib/schedule.mjs). Every subcommand answers the envelope; nothing here
1878
2086
  * launches unless a job is due or run-now is asked. */
1879
- function scheduleCmd() {
2087
+ async function scheduleCmd() {
1880
2088
  const sub = args[1];
1881
2089
  const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
1882
2090
  // One schedule-owning scope for a directory: its deployment (the directory
@@ -1885,7 +2093,11 @@ function scheduleCmd() {
1885
2093
  let scope;
1886
2094
  const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1887
2095
  const io = { hostStatus: () => hostUnitStatus() };
1888
- 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)); };
1889
2101
  const readSpec = () => {
1890
2102
  const inline = flag("spec-json");
1891
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" }); } }
@@ -1897,20 +2109,41 @@ function scheduleCmd() {
1897
2109
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
1898
2110
  try {
1899
2111
  switch (sub) {
1900
- case "list": return out(listSchedules(ws(), io));
1901
- case "show": return out({ schedule: describeSchedule(ws(), needId(), io) });
1902
- 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
+ }
1903
2128
  case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
1904
- case "enable": return out({ schedule: setScheduleEnabled(ws(), needId(), true, io) });
1905
- case "disable": return out({ schedule: setScheduleEnabled(ws(), needId(), false, io) });
1906
- 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() }));
1907
2139
  case "remove": return out(removeSchedule(ws(), needId(), { force: args.includes("--force") }));
1908
- 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() }));
1909
2141
  case "tick": {
1910
2142
  const dryRun = args.includes("--dry-run");
1911
2143
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
1912
2144
  const reg = readRegistry();
1913
- const considered = withHostLock(() => tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], 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 })]);
1914
2147
  return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
1915
2148
  }
1916
2149
  case "host": {
@@ -1920,7 +2153,7 @@ function scheduleCmd() {
1920
2153
  if (op === "status") return out({ scheduler: schedulerStatus(ws(), io) });
1921
2154
  throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
1922
2155
  }
1923
- 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]");
1924
2157
  }
1925
2158
  } catch (e) {
1926
2159
  // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
@@ -1929,6 +2162,101 @@ function scheduleCmd() {
1929
2162
  }
1930
2163
  }
1931
2164
 
2165
+ /** `oats trigger …` (lib/triggers.mjs): event-driven spawns stored beside the schedules of the
2166
+ * deployment scope and evaluated by the same host tick. Every subcommand answers the envelope. */
2167
+ async function triggerCmd() {
2168
+ const sub = args[1];
2169
+ const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
2170
+ const T = await import("../lib/triggers.mjs");
2171
+ let scope;
2172
+ const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
2173
+ const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
2174
+ const needId = () => { if (!id) throw T.triggerError("E_BAD_ARGS", `oats trigger ${sub} <id>`); return id; };
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";
2182
+ try {
2183
+ switch (sub) {
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)");
2197
+ case "test": {
2198
+ let workspaceTeams = null;
2199
+ try {
2200
+ const { observeWorkspace } = await import("../lib/workspace.mjs");
2201
+ const { local } = loadLocal(ws());
2202
+ if (typeof local.workspace === "string") workspaceTeams = Object.keys((await observeWorkspace(local.workspace, { remoteOptions: remoteOptionsFromEnv() })).workspace.teams || {});
2203
+ } catch { /* reported as unknown (null) */ }
2204
+ return out(T.testTrigger(ws(), needId(), { workspaceTeams, ctx: ctx() }), (r) => [
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)"}`,
2207
+ ` gh auth ${r.gh.ok ? `ok — ${r.gh.account ?? "?"} via ${r.gh.credentialSource}` : `FAILED${r.gh.detail ? ` — ${r.gh.detail}` : ""}`}`,
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}`}`,
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}`}`,
2210
+ ` teams ${r.teams.requested.join(", ") || "(none)"}${r.teams.undeclared?.length ? ` undeclared: ${r.teams.undeclared.join(", ")}` : ""}`,
2211
+ ` would fire ${r.wouldFire.length ? r.wouldFire.map((w) => `${w.event} #${w.number}${w.held ? ` (held: ${w.held})` : ""}`).join(", ") : "nothing now"}`,
2212
+ ...r.problems.map((p) => ` problem ${p}`),
2213
+ ...(r.warnings ?? []).map((w) => ` warning ${w}`),
2214
+ ].join("\n"));
2215
+ }
2216
+ case "add": {
2217
+ const file = flag("file"), from = flag("from");
2218
+ if ((file === undefined) === (from === undefined)) throw T.triggerError("E_BAD_ARGS", `oats trigger add needs exactly one of --file <trigger.json> or --from <package>:<template>\n${usage}`);
2219
+ const sets = {};
2220
+ for (let i = 0; i < args.length; i++) if (args[i] === "--set") {
2221
+ const kv = args[i + 1];
2222
+ if (typeof kv !== "string" || !kv.includes("=")) throw T.triggerError("E_BAD_ARGS", "--set needs <name>=<value>");
2223
+ sets[kv.slice(0, kv.indexOf("="))] = kv.slice(kv.indexOf("=") + 1); i++;
2224
+ }
2225
+ const idFlag = flag("id");
2226
+ if (idFlag === true) throw T.triggerError("E_BAD_ARGS", "--id needs a trigger id");
2227
+ let spec;
2228
+ if (file !== undefined) {
2229
+ if (file === true || !existsSync(file)) throw T.triggerError("E_BAD_ARGS", `--file ${file === true ? "needs a path" : `not found: ${file}`}`);
2230
+ if (Object.keys(sets).length) throw T.triggerError("E_BAD_ARGS", "--set fills a package template's parameters; with --file, edit the file");
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" } }); }
2232
+ if (idFlag !== undefined) spec.id = 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);
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)`);
2245
+ }
2246
+ default: throw T.triggerError("E_BAD_ARGS", usage);
2247
+ }
2248
+ } catch (e) {
2249
+ const details = { ...(e.details && typeof e.details === "object" ? e.details : {}), ...(e.field !== undefined ? { field: e.field } : {}) };
2250
+ if (JSON_MODE) jsonFail(e.code || "E_TRIGGER_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
2251
+ }
2252
+ }
2253
+ /** A package trigger template (`triggers: [{ id, file }]` in the locked package's oats-package.json),
2254
+ * read at the lock's commit and instantiated with the --set parameters. */
2255
+ async function triggerFromPackage(T, from, sets, idFlag) {
2256
+ const { bindRemote } = await import("../lib/packages.mjs");
2257
+ return T.packageTriggerTemplate(readLock(scheduleScopeOf(dirFlag())), from, sets, { id: idFlag, remote: bindRemote(remoteModule, remoteOptionsFromEnv()) });
2258
+ }
2259
+
1932
2260
  async function sessionCmd() {
1933
2261
  try {
1934
2262
  if (flag("native-record") !== undefined) throw Object.assign(new Error("--native-record outcome inspection is gone (the captured/portable path was removed in 0.26)"), { code: "E_BAD_ARGS" });
@@ -2175,7 +2503,7 @@ async function capabilityCommand() {
2175
2503
  // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2176
2504
  // already fetched exactly this commit; otherwise the command gets none (never ambient).
2177
2505
  const cachedSoul = hit.soul?.commit ? join(hit.deployment, "agents", hit.soul.name, "souls", String(hit.soul.commit).slice(0, 12)) : null;
2178
- 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);
2179
2507
  }
2180
2508
 
2181
2509
  async function dispatch() {
@@ -2189,14 +2517,14 @@ async function capabilityCommand() {
2189
2517
  // braces: the ids come from instance.json, which spawn wrote from resolved
2190
2518
  // manifests. Null-prototype because the dispatcher indexes it with the
2191
2519
  // namespace the operator typed on the command line.
2192
- let capSettings = Object.create(null);
2520
+ let capSettings = Object.create(null), capOrigins = Object.create(null);
2193
2521
  let deployment = null;
2194
2522
  let soulDir;
2195
2523
  try {
2196
2524
  if (metaFile && existsSync(metaFile)) {
2197
2525
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
2198
2526
  activeIds = (meta.capabilities || []).map((c) => c.id);
2199
- 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 || {}; }
2200
2528
  // Workspace-model homes only (lead decision c3 Q2).
2201
2529
  if (isCapturedHome(meta)) { const e = capturedHomeRefusal(instanceHome, "nothing was dispatched"); bail(e.code, e.message, e.details); }
2202
2530
  if (!isWorkspaceHome(meta)) { const e = preWorkspaceHome(instanceHome, "nothing was dispatched"); bail(e.code, e.message); }
@@ -2235,14 +2563,14 @@ async function capabilityCommand() {
2235
2563
  const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2236
2564
  teamCtx = homeTeamCtx(live.teams, live.source);
2237
2565
  }
2238
- 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);
2239
2567
  }
2240
2568
 
2241
2569
  /** Help / unknown-command / spec validation / exec — shared by every context.
2242
2570
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
2243
2571
  * resolves the directory holding the executable — the operator branch fetches
2244
2572
  * the module tree only when a command is actually going to run). */
2245
- async function runManifestCommand(m, settings, teamCtx, ensureDir, soulDir) {
2573
+ async function runManifestCommand(m, { settings, origins }, teamCtx, ensureDir, soulDir) {
2246
2574
  const sub = args[1];
2247
2575
  const cmds = Object.keys(m.commands);
2248
2576
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -2279,7 +2607,7 @@ async function capabilityCommand() {
2279
2607
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2280
2608
  // invoking process names some other soul (a coordinator's own), never this one.
2281
2609
  const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
2282
- const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
2610
+ const r = spawnSync("node", [abs, ...rest, ...rawArgs.slice(2)], { stdio: "inherit", env: {
2283
2611
  ...inherited, OATS_CAPABILITY: m.capability,
2284
2612
  // Package-runtime boundary: dispatched commands receive the active
2285
2613
  // capability's EFFECTIVE settings (instance snapshot, resolved context, or
@@ -2287,6 +2615,8 @@ async function capabilityCommand() {
2287
2615
  // lifecycle hooks — capabilities read their settings here instead of
2288
2616
  // importing the kernel resolver.
2289
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 || {}),
2290
2620
  // PATH is not a trusted runtime boundary (maintainer finding 1): pass the
2291
2621
  // canonical absolute executable of THIS CLI; official consumers execFile
2292
2622
  // it directly and never resolve `oats` from PATH or a shell.
@@ -2356,7 +2686,7 @@ function versionCmd() {
2356
2686
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2357
2687
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2358
2688
  // never listed.
2359
- 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"], 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 }));
2360
2690
  return;
2361
2691
  }
2362
2692
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2495,7 +2825,7 @@ async function serverRouteCmd() {
2495
2825
  // so its explicit --dir travels; every other routed command takes its
2496
2826
  // scope from the registration.
2497
2827
  const explicitScopeOk = ["inspect", "operation", "launch-config"].includes(cmd);
2498
- if (!explicitScopeOk && (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir=")))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2828
+ if (!explicitScopeOk && flag("dir") !== undefined) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2499
2829
  if (cmd === "launch-config") {
2500
2830
  const action = args[1];
2501
2831
  const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
@@ -2715,13 +3045,16 @@ try {
2715
3045
  // resolved against the current context instead. `version` answers regardless:
2716
3046
  // host protocol negotiation describes this executable.
2717
3047
  {
3048
+ // The kernel's own argv only: a capability command's flags are its provider's to parse.
3049
+ const kernelArgv = (KERNEL_COMMANDS.has(cmd) && !OWN_ARGV_COMMANDS.has(cmd)) || (ROUTED_COMMANDS.has(cmd) && flag("server") !== undefined);
3050
+ if (argvProblem && kernelArgv) cmdFail("E_BAD_ARGS", argvProblem);
2718
3051
  const end = args.indexOf("--"), head = end < 0 ? args : args.slice(0, end);
2719
- const selector = head.find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
3052
+ const selector = head.find((a) => /^--(deployment|resolution|artifact-set)$/.test(a));
2720
3053
  const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
2721
- if (selector) refuse(`${selector.split("=")[0]}: a captured selector is refused (the captured/portable path was removed in 0.26); run the command in its workspace deployment or instance home instead`, { selector: selector.split("=")[0] });
3054
+ if (selector) refuse(`${selector}: a captured selector is refused (the captured/portable path was removed in 0.26); run the command in its workspace deployment or instance home instead`, { selector });
2722
3055
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
2723
3056
  if (inherited.length && cmd !== "version") refuse(`this environment carries a captured context (${inherited.join(", ")}): the captured/portable path was removed in 0.26, and nothing is run against the current context in its place — retire the captured home and re-spawn it from the deployment`, { inherited });
2724
- if (cmd === "inspect" && head.some((a) => a === "--request" || a.startsWith("--request="))) {
3057
+ if (cmd === "inspect" && head.includes("--request")) {
2725
3058
  const message = "oats inspect --request (portable onboarding inspection) is gone (the captured/portable path was removed in 0.26); use `oats onboard` / `oats sync` to set up a workspace and `oats spawn <soul> --preview` to see what a spawn would resolve";
2726
3059
  if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: "inspect --request", replacement: "oats onboard / oats sync; oats spawn --preview" });
2727
3060
  die(message);
@@ -2738,7 +3071,7 @@ else {
2738
3071
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
2739
3072
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
2740
3073
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
2741
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"].includes(cmd)) await serverRouteCmd();
3074
+ if (flag("server") !== undefined && ROUTED_COMMANDS.has(cmd)) await serverRouteCmd();
2742
3075
  else if (cmd === "server") serverCmd();
2743
3076
  else if (cmd === "inspect") await inspectCmd();
2744
3077
  else if (cmd === "operation") await operationCmd();
@@ -2767,7 +3100,9 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
2767
3100
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
2768
3101
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
2769
3102
  else if (cmd === "session") await sessionCmd();
2770
- else if (cmd === "schedule") scheduleCmd();
3103
+ else if (cmd === "schedule") await scheduleCmd();
3104
+ else if (cmd === "trigger") await triggerCmd();
3105
+ else if (cmd === "automations") await automationsCmd();
2771
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; } }
2772
3107
  else if (cmd === "retire") retireCmd();
2773
3108
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -2856,11 +3191,22 @@ Usage:
2856
3191
  next steps (clone members you work IN, spawn
2857
3192
  oats-operator-expert); creates no soul, spawns nothing
2858
3193
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
2859
- 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>
2860
3195
  enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
2861
3196
  tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
2862
3197
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
2863
3198
  routes to that host's workspace
3199
+ oats trigger add (--file <json> | --from <package>:<template> [--set k=v]) | list | show | enable
3200
+ | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3201
+ polled with the host's gh by the schedule tick;
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)
2864
3210
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
2865
3211
  bound to the new instance's home (docs/schedules.md)
2866
3212
  oats session upload --file <path> store a copy of a local file as a private attachment
@@ -2894,6 +3240,9 @@ Usage:
2894
3240
  [--name <slug>] the exact instance name (no <agent>- prefix;
2895
3241
  not with --purpose); must be a slug, not a
2896
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>)
2897
3246
  oats retire <instance> [--force] retire an instance (window, hooks,
2898
3247
  [--self] [--delete-branch] worktree, home); --self = retire the
2899
3248
  [--keep-dir] [--json] CALLING instance: the window dies, then