@awebai/oats 0.27.2 → 0.28.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 (37) hide show
  1. package/bin/oats.mjs +185 -26
  2. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
  3. package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
  4. package/capabilities/oats-okf/injects/okf.md +29 -21
  5. package/capabilities/oats-okf/lib/config.mjs +5 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +500 -0
  7. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  8. package/capabilities/oats-okf/lib/io.mjs +9 -2
  9. package/capabilities/oats-okf/lib/sources.mjs +15 -53
  10. package/capabilities/oats-okf/lib/stores.mjs +10 -7
  11. package/capabilities/oats-okf/lib/worker.mjs +9 -1
  12. package/capabilities/oats-okf/oats.json +13 -4
  13. package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  16. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  17. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  18. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  19. package/docs/desktop-cli-api.md +93 -7
  20. package/docs/oats-local.schema.json +2 -1
  21. package/docs/oats-package.schema.json +39 -0
  22. package/docs/packages.md +67 -3
  23. package/docs/release-notes/v0.28.0.md +144 -0
  24. package/docs/schedules.md +99 -1
  25. package/docs/souls-and-instances.md +7 -3
  26. package/docs/workspaces.md +8 -2
  27. package/lib/core.mjs +22 -4
  28. package/lib/instance-inspect.mjs +4 -4
  29. package/lib/instance-resolution.mjs +62 -18
  30. package/lib/materialize.mjs +13 -0
  31. package/lib/packages.mjs +90 -6
  32. package/lib/resolve.mjs +20 -2
  33. package/lib/schedule.mjs +24 -11
  34. package/lib/triggers.mjs +545 -0
  35. package/lib/workspace.mjs +80 -3
  36. package/package-catalog.json +1 -1
  37. package/package.json +1 -1
package/bin/oats.mjs CHANGED
@@ -32,12 +32,13 @@ import {
32
32
  import {
33
33
  writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages,
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 { tickTriggers } from "../lib/triggers.mjs";
41
42
  import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
42
43
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
43
44
  import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
@@ -48,10 +49,32 @@ const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli }
48
49
  import { homeTarget, soulTarget, isWorkspaceContext, inspectDocument, readinessDocument, policyOf, policySoul, manifestMissingRequires, INSPECT_OPERATIONS_API } from "../lib/instance-inspect.mjs";
49
50
  import { readEvents } from "../lib/instance-events.mjs";
50
51
 
51
- const args = process.argv.slice(2);
52
+ const rawArgs = process.argv.slice(2);
53
+ /** The kernel's switches: a value never rides one (`--yolo=false` must not turn yolo on). */
54
+ 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"]);
55
+ /** `--flag=value` is `--flag value`: every kernel reader (flag(), valueFlag(), the onboard and
56
+ * routed-command loops) then applies the spaced form's validation to it. `problem` is an empty
57
+ * `--flag=` or a switch given a value. */
58
+ function expandInlineValues(argv) {
59
+ const out = [];
60
+ let problem;
61
+ for (const a of argv) {
62
+ const eq = a.indexOf("=");
63
+ if (!a.startsWith("--") || eq <= 2) { out.push(a); continue; }
64
+ const name = a.slice(2, eq), value = a.slice(eq + 1);
65
+ problem ??= KERNEL_SWITCHES.has(name) ? `--${name} takes no value (got ${a})` : value === "" ? `--${name}= needs a value` : undefined;
66
+ out.push(`--${name}`, value);
67
+ }
68
+ return { argv: out, problem };
69
+ }
70
+ const { argv: args, problem: argvProblem } = expandInlineValues(rawArgs);
52
71
  let cmd = args[0];
53
72
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
54
73
  const KERNEL_COMMANDS = new Set(["capture", "capabilities", "doctor", "inspect", "instance", "operation", "package", "readiness", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "update", "version", "workspace"]);
74
+ /** Commands whose argv another parser reads (packages/record and packages/experimental parse process.argv). */
75
+ const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]);
76
+ /** Commands `--server <id>` runs on a registered server. */
77
+ const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"]);
55
78
  const flag = (name) => {
56
79
  const i = args.indexOf(`--${name}`);
57
80
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -154,7 +177,7 @@ function operationalKnowledgeNote(composition, soulName) {
154
177
  * home-relative (`.oats/modules/<cap>/<inject>`), where an instance carries them. */
155
178
  async function doctorComposition(ctx, soulName, ws, bail) {
156
179
  if (!soulName) return undefined;
157
- const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
180
+ const { prepareInstance, previewWorkspaceSoul, materializePrepared, discoverOrStandalone, agentDirOf } = await import("../lib/instance-resolution.mjs");
158
181
  const deployment = dirname(ws.local.path);
159
182
  const root = join(deployment, "agents");
160
183
  const remoteOptions = remoteOptionsFromEnv();
@@ -162,11 +185,11 @@ async function doctorComposition(ctx, soulName, ws, bail) {
162
185
  // A bail exits the process without unwinding: the temporary copies are removed at exit too.
163
186
  process.once("exit", () => { for (const c of cleanups) { try { c(); } catch { /* best effort */ } } });
164
187
  try {
165
- const discovery = await discoverOrStandalone(loadLocal(deployment).local, { remoteOptions });
188
+ const discovery = await discoverOrStandalone(loadLocal(deployment).local, { deployment, remoteOptions });
166
189
  const prepared = await prepareInstance(deployment, soulName, { remoteOptions, discovery });
167
190
  const pv = await previewWorkspaceSoul(prepared, root);
168
191
  cleanups.push(pv.cleanup);
169
- const agent = findAgentAt(root, prepared.soulEntry.name, pv.soulDir);
192
+ const agent = findAgentAt(root, agentDirOf(prepared.soulEntry), pv.soulDir);
170
193
  if (!agent) bail("E_SOUL_UNKNOWN", `soul "${soulName}" was fetched but is not readable as a soul`);
171
194
  const composition = composeInstanceAgentsMd(pv.soulDir, deployment, agent.name, agent.work || "checkout", agent.kind, prepared);
172
195
  const scratch = realpathSync(mkdtempSync(join(tmpdir(), "oats-doctor-home-")));
@@ -1035,7 +1058,7 @@ function standaloneCatalogVersion(ref) {
1035
1058
  async function discoverForCli(ctx, bail) {
1036
1059
  // The standalone case (decisions 10/25) is a discovery too: the repo's own view
1037
1060
  // 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 }); }
1061
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions }); }
1039
1062
  catch (e) {
1040
1063
  if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1041
1064
  throw e;
@@ -1064,6 +1087,9 @@ function workspaceItems(discovery, lock) {
1064
1087
  const s = ext.soul;
1065
1088
  souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? (s.team ? [s.team] : []))], private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1066
1089
  }
1090
+ for (const s of discovery.packageSouls || []) {
1091
+ souls.push({ name: s.name, qualifiedName: s.qualifiedName, origin: originOf(s), kind: "package", package: s.package, version: s.version, repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), labels: [...(s.labels ?? [])], private: false, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1092
+ }
1067
1093
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1068
1094
  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 });
1069
1095
  }
@@ -1083,7 +1109,7 @@ function memberRows(discovery) {
1083
1109
 
1084
1110
  /** Package rows for `sync` / `workspace status` from the lock. */
1085
1111
  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 }));
1112
+ 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
1113
  }
1088
1114
 
1089
1115
  /** Print a padded table: rows are arrays of strings. */
@@ -1124,6 +1150,17 @@ async function performSync(ctx, bail, { onDiscovered } = {}) {
1124
1150
  const lock = resolved.lock;
1125
1151
  let lockFile;
1126
1152
  try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1153
+ // Package souls come from the lock: list what THIS sync locked, not what the previous lock held.
1154
+ if (discovery.standalone !== true) {
1155
+ let pkg;
1156
+ try { pkg = await discoverPackageSouls(discovery.workspace, lock, { remoteOptions: ctx.remoteOptions }); }
1157
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1158
+ const kept = (list) => list.filter((p) => typeof p.package !== "string");
1159
+ discovery.packageSouls = pkg.souls;
1160
+ discovery.problems = [...kept(discovery.problems), ...pkg.problems];
1161
+ discovery.warnings = workspaceWarnings(discovery.workspace, discovery.members, discovery.external || [], pkg.souls);
1162
+ problems.splice(0, problems.length, ...kept(problems), ...pkg.problems);
1163
+ }
1127
1164
  // The deployment's instance root: <deployment>/agents/ (findRoot's marker). A hand-written
1128
1165
  // oats-local.yaml + sync is a complete deployment; spawn must not answer E_NO_DEPLOYMENT after it.
1129
1166
  try { mkdirSync(join(ctx.deploymentDir, "agents"), { recursive: true }); } catch { /* reported by spawn's E_NO_DEPLOYMENT remedy if it matters */ }
@@ -1152,6 +1189,7 @@ function printSyncReport(ctx, synced) {
1152
1189
  const { report, discovery, items, lockFile } = synced;
1153
1190
  const { members, packages, changes } = report;
1154
1191
  const disabled = new Set(ctx.local.souls?.disabled || []);
1192
+ const isDisabled = (s) => disabled.has(s.name) || disabled.has(s.qualifiedName ?? `${memberLabel(s.repoKey)}/${s.name}`);
1155
1193
  console.log(`workspace ${discovery.workspace?.name ?? `(${standaloneNote(discovery)})`} (${discovery.key} @ ${short(discovery.commit)})`);
1156
1194
  console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
1157
1195
  console.log(`packages ${packages.map((p) => `${p.id} ${p.version} ✓ (@ ${short(p.commit)})`).join(" ") || "(none)"}`);
@@ -1162,8 +1200,9 @@ function printSyncReport(ctx, synced) {
1162
1200
  const externalSouls = items.souls.filter((s) => s.kind === "external");
1163
1201
  // Souls have no private mode (0.26.0); the private count is of repo-owned member capabilities.
1164
1202
  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("; ")})` : ""}`);
1203
+ const packageSouls = items.souls.filter((s) => s.kind === "package");
1204
+ const disabledHere = items.souls.filter(isDisabled);
1205
+ 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
1206
  const teams = new Map();
1168
1207
  for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
1169
1208
  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); }
@@ -1294,7 +1333,7 @@ async function workspaceCmd() {
1294
1333
  for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
1295
1334
  console.log("\nPackages:");
1296
1335
  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(",")]));
1336
+ 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
1337
  if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
1299
1338
  if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
1300
1339
  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(" ")}`);
@@ -1343,7 +1382,7 @@ async function statusDrift(data) {
1343
1382
  const souls = new Map();
1344
1383
  for (const a of data) {
1345
1384
  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 }); }
1385
+ 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 } : {}) }); }
1347
1386
  catch { /* not a workspace soul (classic, capability agent, or unreadable stamp) */ }
1348
1387
  }
1349
1388
  const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
@@ -1354,7 +1393,7 @@ async function statusDrift(data) {
1354
1393
  let discovery;
1355
1394
  // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
1356
1395
  // 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() }); }
1396
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { lock, remoteOptions: remoteOptionsFromEnv() }); }
1358
1397
  catch (e) {
1359
1398
  const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
1360
1399
  return { drift: new Map(), soul: new Map(), souls, unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
@@ -1369,6 +1408,7 @@ async function statusDrift(data) {
1369
1408
  }
1370
1409
  // The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
1371
1410
  for (const [, stamp] of souls) {
1411
+ 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
1412
  const member = discovery.members.find((m) => m && m.key === stamp.repoKey);
1373
1413
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1374
1414
  }
@@ -1385,6 +1425,12 @@ function driftLine(row) {
1385
1425
  }
1386
1426
  /** The `soul:` line of an instance — decision 17 for the soul source. */
1387
1427
  function soulDriftLine(row, agentName) {
1428
+ if (row.package) {
1429
+ const base = `soul: ${row.name ?? agentName} from package ${row.package} v${row.version} @ ${short7(row.commit)}`;
1430
+ if (row.status === "moved") return `${base} [package moved since (now v${row.current?.version} @ ${short7(row.current?.commit)})]`;
1431
+ if (row.status === "missing") return `${base} [${row.reason === "soul-absent" ? "soul no longer shipped by the package" : "package no longer locked"}]`;
1432
+ return base;
1433
+ }
1388
1434
  const base = `soul: ${row.name ?? agentName} from ${memberLabel(row.repoKey)} @ ${short7(row.commit)}`;
1389
1435
  if (row.status === "moved") return `${base} [member moved since (now @ ${short7(row.current?.commit)})]`;
1390
1436
  if (row.status === "missing") return `${base} [${row.reason === "soul-absent" ? "soul no longer present" : `member ${row.reason || "unconfirmed"}`}]`;
@@ -1394,6 +1440,7 @@ function soulDriftLine(row, agentName) {
1394
1440
  function soulRepoLabel(a, ws) {
1395
1441
  const stamp = ws?.souls?.get(a.name);
1396
1442
  if (!stamp) return a.repo || "?";
1443
+ if (typeof stamp.package === "string") return `package ${stamp.package} v${stamp.version} @ ${short7(stamp.commit)}`;
1397
1444
  const moved = stamp.current && stamp.commit && stamp.current !== stamp.commit ? ` (member now @ ${short7(stamp.current)})` : "";
1398
1445
  return `${memberLabel(stamp.repoKey)} @ ${short7(stamp.commit)}${moved}`;
1399
1446
  }
@@ -1417,7 +1464,7 @@ async function status() {
1417
1464
  const rows = ws.drift.get(key);
1418
1465
  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
1466
  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 } : {}) };
1467
+ 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
1468
  }
1422
1469
  }
1423
1470
  console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
@@ -1503,12 +1550,12 @@ async function spawnCmd() {
1503
1550
  {
1504
1551
  let discovery = null;
1505
1552
  try {
1506
- const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1553
+ const { prepareInstance, ensureWorkspaceSoul, previewWorkspaceSoul, parseProviderFlags, discoverOrStandalone, agentDirOf } = await import("../lib/instance-resolution.mjs");
1507
1554
  const remoteOptions = remoteOptionsFromEnv();
1508
- const { local } = loadLocal(dirFlag());
1509
- discovery = wsDiscovery = await discoverOrStandalone(local, { remoteOptions });
1555
+ const { local, path: localPath } = loadLocal(dirFlag());
1556
+ discovery = wsDiscovery = await discoverOrStandalone(local, { deployment: dirname(localPath), remoteOptions });
1510
1557
  wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
1511
- const soulName = wsPrepared.soulEntry.name;
1558
+ const soulName = agentDirOf(wsPrepared.soulEntry);
1512
1559
  let soulDir;
1513
1560
  if (isPreview) {
1514
1561
  // A preview writes nothing in the deployment: the soul comes from the
@@ -1616,6 +1663,17 @@ async function spawnCmd() {
1616
1663
  const taskFileFlag = flag("task-file");
1617
1664
  if (taskFileFlag === true) bail("E_BAD_ARGS", "--task-file needs a path");
1618
1665
  if (taskFileFlag && !existsSync(taskFileFlag)) bail("E_BAD_ARGS", `--task-file not found: ${taskFileFlag}`);
1666
+ // --trigger-event <file>: the event a trigger spawns this instance for (lib/triggers.mjs). It is
1667
+ // copied to <home>/.oats/trigger-event.json (OATS_TRIGGER_EVENT_FILE) and recorded as instance.json.trigger.
1668
+ let triggerEvent;
1669
+ { const f = flag("trigger-event");
1670
+ if (f === true) bail("E_BAD_ARGS", "--trigger-event needs a path");
1671
+ if (f !== undefined) {
1672
+ if (!existsSync(f)) bail("E_BAD_ARGS", `--trigger-event not found: ${f}`);
1673
+ try { triggerEvent = JSON.parse(readFileSync(f, "utf8")); } catch (e) { bail("E_BAD_ARGS", `--trigger-event is not valid JSON: ${e.message}`); }
1674
+ 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";
1675
+ if (!ok) bail("E_BAD_ARGS", "--trigger-event must hold { trigger, source, repo, number, url, event, headSha, labels, observedAt }");
1676
+ } }
1619
1677
  const relativeRoot = flag("relative-root");
1620
1678
  if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
1621
1679
  if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
@@ -1684,6 +1742,7 @@ async function spawnCmd() {
1684
1742
  work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
1685
1743
  launchConfig: valueFlag("launch-config"),
1686
1744
  launch: !args.includes("--no-launch"),
1745
+ ...(triggerEvent ? { triggerEvent } : {}),
1687
1746
  // K6: --preview decides everything and touches nothing; --base <ref>
1688
1747
  // selects a worktree's start point; --model @native-default is the
1689
1748
  // explicit "harness's own default" (distinct from omitting --model).
@@ -1897,7 +1956,7 @@ function scheduleCmd() {
1897
1956
  const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
1898
1957
  try {
1899
1958
  switch (sub) {
1900
- case "list": return out(listSchedules(ws(), io));
1959
+ case "list": { const r = listSchedules(ws(), io); out(r); if (!JSON_MODE && r.triggers.count) console.log(`${r.triggers.count} trigger${r.triggers.count === 1 ? " is" : "s are"} not listed here: ${r.triggers.command}`); return; }
1901
1960
  case "show": return out({ schedule: describeSchedule(ws(), needId(), io) });
1902
1961
  case "add": { const spec = readSpec(); if (id && spec.id === undefined) spec.id = id; if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" }); return out({ schedule: addSchedule(ws(), spec, io) }); }
1903
1962
  case "update": return out({ schedule: updateSchedule(ws(), needId(), readSpec(), io) });
@@ -1910,7 +1969,7 @@ function scheduleCmd() {
1910
1969
  const dryRun = args.includes("--dry-run");
1911
1970
  if (args.includes("--host")) return out(tickHost({ io, dryRun }));
1912
1971
  const reg = readRegistry();
1913
- const considered = withHostLock(() => tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun }));
1972
+ const considered = withHostLock(() => [...tickWorkspace(ws(), { io, reg, wsList: reg.workspaces.includes(ws()) ? reg.workspaces : [...reg.workspaces, ws()], dryRun }), ...tickTriggers(ws(), { io, dryRun })]);
1914
1973
  return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws(), io) });
1915
1974
  }
1916
1975
  case "host": {
@@ -1929,6 +1988,98 @@ function scheduleCmd() {
1929
1988
  }
1930
1989
  }
1931
1990
 
1991
+ /** `oats trigger …` (lib/triggers.mjs): event-driven spawns stored beside the schedules of the
1992
+ * deployment scope and evaluated by the same host tick. Every subcommand answers the envelope. */
1993
+ async function triggerCmd() {
1994
+ const sub = args[1];
1995
+ const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
1996
+ const T = await import("../lib/triggers.mjs");
1997
+ let scope;
1998
+ const ws = () => (scope ??= scheduleScopeOf(dirFlag()));
1999
+ const out = (result, text) => { if (JSON_MODE) jsonOk(result); else console.log(text ? text(result) : JSON.stringify(result, null, 2)); };
2000
+ const needId = () => { if (!id) throw T.triggerError("E_BAD_ARGS", `oats trigger ${sub} <id>`); return id; };
2001
+ const usage = "usage: oats trigger add (--file <trigger.json> | --from <package>:<template> [--set <name>=<value>]… [--id <id>]) | list | show <id> | enable <id> | disable <id> | remove <id> | test <id> | status [<id>] [--dir <deployment>] [--json]";
2002
+ const line = (t) => `${t.id} ${t.enabled ? "enabled " : "disabled"} ${t.on?.source ?? "?"} ${t.on?.repo ?? "?"} [${(t.on?.events || []).join(",")}]${t.on?.labels?.length ? ` labels ${t.on.labels.join(",")}` : ""} every ${t.on?.poll ?? "?"} → spawn ${t.spawn?.soul ?? "?"}${t.spawn?.teams?.length ? ` in ${t.spawn.teams.join(",")}` : ""}${t.invalid ? ` INVALID: ${t.invalid.message}` : ""}`;
2003
+ try {
2004
+ switch (sub) {
2005
+ case "list": return out(T.listTriggers(ws()), (r) => r.triggers.length ? r.triggers.map(line).join("\n") : "(no triggers — oats trigger add --file <trigger.json> | --from <package>:<template>)");
2006
+ case "show": return out({ trigger: T.describeTrigger(ws(), needId()) });
2007
+ case "enable": return out({ trigger: T.setTriggerEnabled(ws(), needId(), true) }, (r) => line(r.trigger));
2008
+ case "disable": return out({ trigger: T.setTriggerEnabled(ws(), needId(), false) }, (r) => line(r.trigger));
2009
+ case "remove": return out(T.removeTrigger(ws(), needId()), (r) => `removed trigger ${r.removed}${r.live.length ? ` (its live instances keep running: ${r.live.join(", ")})` : ""}`);
2010
+ case "status": return out(T.triggerStatus(ws(), id), (r) => r.triggers.map((t) => `${t.id} last poll ${t.lastPoll ? `${t.lastPoll.at} ${t.lastPoll.ok ? `ok (${t.lastPoll.matching}/${t.lastPoll.prs} PRs match)` : `FAILED: ${t.lastPoll.error}`}` : "never"} pending ${t.pending.length} fired ${t.firedTotal} live ${t.live.map((l) => l.instance).join(",") || "none"}${t.lastError ? `\n last error ${t.lastError.at}: ${t.lastError.message}` : ""}`).join("\n") || "(no triggers)");
2011
+ case "test": {
2012
+ let workspaceTeams = null;
2013
+ try {
2014
+ const { observeWorkspace } = await import("../lib/workspace.mjs");
2015
+ const { local } = loadLocal(ws());
2016
+ if (typeof local.workspace === "string") workspaceTeams = Object.keys((await observeWorkspace(local.workspace, { remoteOptions: remoteOptionsFromEnv() })).workspace.teams || {});
2017
+ } catch { /* reported as unknown (null) */ }
2018
+ return out(T.testTrigger(ws(), needId(), { workspaceTeams }), (r) => [
2019
+ `trigger ${r.id}: ${r.ok ? "ready" : "NOT ready"} (nothing was spawned)`,
2020
+ ` gh auth ${r.gh.ok ? `ok — ${r.gh.account ?? "?"} via ${r.gh.credentialSource}` : `FAILED${r.gh.detail ? ` — ${r.gh.detail}` : ""}`}`,
2021
+ ` repo ${r.repo.key} ${r.repo.readable ? `readable; push ${r.repo.permissions.push} maintain ${r.repo.permissions.maintain} admin ${r.repo.permissions.admin}` : `NOT readable: ${r.repo.error}`}`,
2022
+ ` 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}`}`,
2023
+ ` teams ${r.teams.requested.join(", ") || "(none)"}${r.teams.undeclared?.length ? ` undeclared: ${r.teams.undeclared.join(", ")}` : ""}`,
2024
+ ` would fire ${r.wouldFire.length ? r.wouldFire.map((w) => `${w.event} #${w.number}${w.held ? ` (held: ${w.held})` : ""}`).join(", ") : "nothing now"}`,
2025
+ ...r.problems.map((p) => ` problem ${p}`),
2026
+ ...(r.warnings ?? []).map((w) => ` warning ${w}`),
2027
+ ].join("\n"));
2028
+ }
2029
+ case "add": {
2030
+ const file = flag("file"), from = flag("from");
2031
+ 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}`);
2032
+ const sets = {};
2033
+ for (let i = 0; i < args.length; i++) if (args[i] === "--set") {
2034
+ const kv = args[i + 1];
2035
+ if (typeof kv !== "string" || !kv.includes("=")) throw T.triggerError("E_BAD_ARGS", "--set needs <name>=<value>");
2036
+ sets[kv.slice(0, kv.indexOf("="))] = kv.slice(kv.indexOf("=") + 1); i++;
2037
+ }
2038
+ const idFlag = flag("id");
2039
+ if (idFlag === true) throw T.triggerError("E_BAD_ARGS", "--id needs a trigger id");
2040
+ let spec;
2041
+ if (file !== undefined) {
2042
+ if (file === true || !existsSync(file)) throw T.triggerError("E_BAD_ARGS", `--file ${file === true ? "needs a path" : `not found: ${file}`}`);
2043
+ if (Object.keys(sets).length) throw T.triggerError("E_BAD_ARGS", "--set fills a package template's parameters; with --file, edit the file");
2044
+ try { spec = JSON.parse(readFileSync(file, "utf8")); } catch (e) { throw T.triggerError("E_TRIGGER_INVALID", `${file} is not valid JSON: ${e.message}`, { details: { field: "file" } }); }
2045
+ if (idFlag !== undefined) spec.id = idFlag;
2046
+ } else spec = await triggerFromPackage(T, String(from), sets, idFlag);
2047
+ return out({ trigger: T.addTrigger(ws(), spec) }, (r) => `added ${line(r.trigger)}\n(\`oats trigger test ${r.trigger.id}\` checks gh, the repository, the soul and the teams on this host)`);
2048
+ }
2049
+ default: throw T.triggerError("E_BAD_ARGS", usage);
2050
+ }
2051
+ } catch (e) {
2052
+ const details = { ...(e.details && typeof e.details === "object" ? e.details : {}), ...(e.field !== undefined ? { field: e.field } : {}) };
2053
+ if (JSON_MODE) jsonFail(e.code || "E_TRIGGER_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
2054
+ }
2055
+ }
2056
+ /** A package trigger template (`triggers: [{ id, file }]` in the locked package's oats-package.json),
2057
+ * read at the lock's commit and instantiated with the --set parameters. */
2058
+ async function triggerFromPackage(T, from, sets, idFlag) {
2059
+ const m = /^([a-z0-9][a-z0-9._-]*):([a-z0-9][a-z0-9._-]*)$/.exec(from);
2060
+ if (!m) throw T.triggerError("E_BAD_ARGS", `--from ${JSON.stringify(from)}: write <package>:<template>, e.g. oats.okf:harvest-review`);
2061
+ const [, pkg, template] = m;
2062
+ const deployment = scheduleScopeOf(dirFlag());
2063
+ const lock = readLock(deployment);
2064
+ const entry = lock.packages[pkg];
2065
+ if (!entry) throw T.triggerError("E_PACKAGE_MISSING", `package ${pkg} is not in ${LOCK_FILE} — declare it in packages: and run \`oats sync\``, { details: { package: pkg } });
2066
+ const { lockedPackageRef, bindRemote } = await import("../lib/packages.mjs");
2067
+ const ref = lockedPackageRef(entry);
2068
+ const remote = bindRemote(remoteModule, remoteOptionsFromEnv());
2069
+ const read = async (rel, what) => {
2070
+ let bytes;
2071
+ try { ({ bytes } = await remote.readRemoteFile(ref, entry.commit, `${entry.path}/${rel}`)); }
2072
+ catch (e) { throw T.triggerError(e?.code === "E_REMOTE_PATH_MISSING" ? "E_PACKAGE_MANIFEST" : (e?.code || "E_REMOTE_UNREADABLE"), `${pkg} v${entry.version}: ${what} ${entry.path}/${rel} cannot be read: ${e.message}`, { details: { package: pkg, path: rel } }); }
2073
+ try { return JSON.parse(Buffer.from(bytes).toString("utf8")); } catch (e) { throw T.triggerError("E_PACKAGE_MANIFEST", `${pkg} v${entry.version}: ${what} ${entry.path}/${rel} is not valid JSON: ${e.message}`, { details: { package: pkg, path: rel } }); }
2074
+ };
2075
+ const manifest = await read("oats-package.json", "the package manifest");
2076
+ const listed = Array.isArray(manifest.triggers) ? manifest.triggers : [];
2077
+ const row = listed.find((t) => t && t.id === template);
2078
+ if (!row || typeof row.file !== "string" || !row.file || row.file.split("/").includes("..") || row.file.startsWith("/")) throw T.triggerError("E_TRIGGER_UNKNOWN", `package ${pkg} v${entry.version} has no trigger template ${JSON.stringify(template)} (templates: ${listed.map((t) => t?.id).filter(Boolean).join(", ") || "none"})`, { details: { package: pkg, template, templates: listed.map((t) => t?.id).filter(Boolean) } });
2079
+ const doc = await read(row.file, `trigger template ${template}`);
2080
+ return T.instantiateTemplate(doc, sets, { id: idFlag, provenance: { package: pkg, version: entry.version, commit: entry.commit, template } });
2081
+ }
2082
+
1932
2083
  async function sessionCmd() {
1933
2084
  try {
1934
2085
  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" });
@@ -2279,7 +2430,7 @@ async function capabilityCommand() {
2279
2430
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2280
2431
  // invoking process names some other soul (a coordinator's own), never this one.
2281
2432
  const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
2282
- const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
2433
+ const r = spawnSync("node", [abs, ...rest, ...rawArgs.slice(2)], { stdio: "inherit", env: {
2283
2434
  ...inherited, OATS_CAPABILITY: m.capability,
2284
2435
  // Package-runtime boundary: dispatched commands receive the active
2285
2436
  // capability's EFFECTIVE settings (instance snapshot, resolved context, or
@@ -2356,7 +2507,7 @@ function versionCmd() {
2356
2507
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2357
2508
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2358
2509
  // 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 }));
2510
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "teams", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2360
2511
  return;
2361
2512
  }
2362
2513
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2495,7 +2646,7 @@ async function serverRouteCmd() {
2495
2646
  // so its explicit --dir travels; every other routed command takes its
2496
2647
  // scope from the registration.
2497
2648
  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");
2649
+ if (!explicitScopeOk && flag("dir") !== undefined) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2499
2650
  if (cmd === "launch-config") {
2500
2651
  const action = args[1];
2501
2652
  const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
@@ -2715,13 +2866,16 @@ try {
2715
2866
  // resolved against the current context instead. `version` answers regardless:
2716
2867
  // host protocol negotiation describes this executable.
2717
2868
  {
2869
+ // The kernel's own argv only: a capability command's flags are its provider's to parse.
2870
+ const kernelArgv = (KERNEL_COMMANDS.has(cmd) && !OWN_ARGV_COMMANDS.has(cmd)) || (ROUTED_COMMANDS.has(cmd) && flag("server") !== undefined);
2871
+ if (argvProblem && kernelArgv) cmdFail("E_BAD_ARGS", argvProblem);
2718
2872
  const end = args.indexOf("--"), head = end < 0 ? args : args.slice(0, end);
2719
- const selector = head.find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
2873
+ const selector = head.find((a) => /^--(deployment|resolution|artifact-set)$/.test(a));
2720
2874
  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] });
2875
+ 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
2876
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
2723
2877
  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="))) {
2878
+ if (cmd === "inspect" && head.includes("--request")) {
2725
2879
  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
2880
  if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: "inspect --request", replacement: "oats onboard / oats sync; oats spawn --preview" });
2727
2881
  die(message);
@@ -2738,7 +2892,7 @@ else {
2738
2892
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
2739
2893
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
2740
2894
  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();
2895
+ if (flag("server") !== undefined && ROUTED_COMMANDS.has(cmd)) await serverRouteCmd();
2742
2896
  else if (cmd === "server") serverCmd();
2743
2897
  else if (cmd === "inspect") await inspectCmd();
2744
2898
  else if (cmd === "operation") await operationCmd();
@@ -2768,6 +2922,7 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
2768
2922
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
2769
2923
  else if (cmd === "session") await sessionCmd();
2770
2924
  else if (cmd === "schedule") scheduleCmd();
2925
+ else if (cmd === "trigger") await triggerCmd();
2771
2926
  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
2927
  else if (cmd === "retire") retireCmd();
2773
2928
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -2861,6 +3016,10 @@ Usage:
2861
3016
  tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
2862
3017
  host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
2863
3018
  routes to that host's workspace
3019
+ oats trigger add (--file <json> | --from <package>:<template> [--set k=v]) | list | show | enable
3020
+ | disable | remove <id> | test <id> | status [<id>] event-driven spawns (github.pull_request
3021
+ polled with the host's gh by the schedule tick;
3022
+ see docs/schedules.md#triggers)
2864
3023
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
2865
3024
  bound to the new instance's home (docs/schedules.md)
2866
3025
  oats session upload --file <path> store a copy of a local file as a private attachment
@@ -10,9 +10,14 @@ worktree, or write accepted bases directly. Only the listed owned nodes and
10
10
  base navigation are editable. Read other nodes as context.
11
11
 
12
12
  You are a service: no STATE.md/log.md/notes upkeep and no recursive capture.
13
- The working-agent read-only injection below applies to ordinary sources, not
14
- to the explicitly listed STAGED roots in your task. Accepted bases remain
15
- read-only even for you: the completion command performs provider publication.
13
+ No okf injection is composed for you (capability agents get none), so the
14
+ working-agent consultation protocol does not apply to you. Judge and edit the
15
+ owned nodes from your STAGED roots in ./work (listed in staging.json). Do NOT
16
+ use `oats okf index`, `cat` or `search` for those nodes: they serve the
17
+ accepted state, not your staging, and would hide the changes you are judging.
18
+ Use them, if at all, only for read-only context from nodes you do not edit.
19
+ Accepted bases remain read-only even for you: the completion command performs
20
+ provider publication.
16
21
 
17
22
  Write the explicit judgment receipt, call the task's safely quoted completion
18
23
  command, and inspect its result. A failed/uncertain command is NOT success:
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto';
3
- import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fail, unlock } from '../lib/io.mjs';
4
- import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
5
- import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, views } from '../lib/sources.mjs';
2
+ import { fs, join, resolve, readJSON, safePath, oats, fail, unlock } from '../lib/io.mjs';
3
+ import { loadBindings } from '../lib/config.mjs';
4
+ import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath } from '../lib/sources.mjs';
5
+ import { CONSULT } from '../lib/consult.mjs';
6
6
  import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
7
7
  import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource, forgetMigration } from '../lib/migration.mjs';
8
8
  import { inspect } from '../lib/inspection.mjs';
@@ -13,8 +13,16 @@ oats okf harvest [--home PATH] [--no-launch] [--json]
13
13
  oats okf run-source --source FILE [--manual] [--no-launch] [--json]
14
14
  oats okf complete --source FILE --run ID --judgment FILE [--json]
15
15
  oats okf retry --source FILE [--run ID --rejudge | --rejudge | --launch | --adopt-home PATH] [--json]
16
- oats okf read [--home PATH | --source FILE] --base ALIAS [--path node/index.md] [--json]
17
- oats okf refresh [--home PATH | --source FILE] [--json]
16
+ oats okf bases [--fresh] [--json]
17
+ oats okf index [--base ALIAS] [NODE | ALIAS/NODE] [--fresh] [--json]
18
+ oats okf cat --base ALIAS PATH [--from PATH] [--fresh] [--json]
19
+ oats okf ls --base ALIAS [DIR] [--fresh] [--json]
20
+ oats okf links --base ALIAS PATH [--fresh] [--json]
21
+ oats okf search [--base ALIAS | --all] [--node NODE] [--regex] [--case-sensitive] TEXT [--fresh] [--json]
22
+ oats okf read --base ALIAS [--path node/index.md] [--json] (2.x alias of cat)
23
+ Consult commands read the accepted state remotely (host cache, no local copy);
24
+ also accept --home PATH | --source FILE. PATH is /node/x.md from the base root,
25
+ relative to --from's directory, or bare node/x.md from the root.
18
26
  oats okf setup --source FILE [--enable | --disable] [--install-host] [--json]
19
27
  oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
20
28
  oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
@@ -36,11 +44,13 @@ if(args.includes('--help') || args.includes('-h')) {process.stdout.write(HELP);}
36
44
  else {
37
45
  const event=process.env.OATS_EVENT || args[0];
38
46
  const hook=['spawn','retire','soul-scaffold'].includes(event);
39
- let exit=0,answer;
47
+ const consult=Object.hasOwn(CONSULT,event);
48
+ let exit=0,answer,text,textMode=consult && !args.includes('--json');
40
49
  try {
41
- const flags={}; const boolean=new Set(['json','no-launch','manual','rejudge','launch','enable','disable','install-host','confirm']);
50
+ const flags={},positionals=[]; const boolean=new Set(['json','no-launch','manual','rejudge','launch','enable','disable','install-host','confirm','fresh','all','regex','case-sensitive']);
42
51
  for(let i=1;i<args.length;i++) {
43
- if(!args[i].startsWith('--')) fail('E_USAGE',`unexpected argument ${args[i]}`);
52
+ if(consult && args[i]==='--') {positionals.push(...args.slice(i+1));break;}
53
+ if(!args[i].startsWith('--')) {if(!consult || event==='read') fail('E_USAGE',`unexpected argument ${args[i]}`);positionals.push(args[i]);continue;}
44
54
  const k=args[i].slice(2);if(k in flags) fail('E_USAGE',`duplicate --${k}`);
45
55
  if(boolean.has(k)) flags[k]=true;
46
56
  else {if(!args[i+1] || args[i+1].startsWith('--')) fail('E_USAGE',`--${k} needs a value`);flags[k]=args[++i];}
@@ -49,7 +59,9 @@ else {
49
59
  spawn:[],retire:['home'], 'soul-scaffold':[],
50
60
  harvest:['home','no-launch','native-request','worker-mode'],inspect:['home','source'],
51
61
  'run-source':['source','manual','no-launch'],complete:['source','run','judgment'],
52
- retry:['source','run','rejudge','launch','adopt-home'],read:['home','source','base','path'],refresh:['home','source'],
62
+ retry:['source','run','rejudge','launch','adopt-home'],read:['home','source','base','path','fresh'],refresh:['home','source'],
63
+ bases:['home','source','fresh'],index:['home','source','base','fresh'],cat:['home','source','base','from','fresh'],ls:['home','source','base','fresh'],
64
+ links:['home','source','base','fresh'],search:['home','source','base','all','node','regex','case-sensitive','fresh'],
53
65
  setup:['source','enable','disable','install-host'],init:['base','nodes','output','confirm'],
54
66
  migrate:['source-home','legacy','base','node','output','deliver','cutover','soul-dir'],unlock:['lock','token']
55
67
  };
@@ -107,7 +119,9 @@ else {
107
119
  readRun(s,id);return s;
108
120
  };
109
121
  let result;
110
- if(event==='soul-scaffold') {
122
+ if(consult) {const answer=CONSULT[event](src(),flags,positionals);result=answer.result;text=answer.text;}
123
+ else if(event==='refresh') fail('E_REMOVED','okf 3.0.0 has no per-instance views; index/cat always read the accepted state: run `oats okf index`, then `oats okf cat --base ALIAS PATH`');
124
+ else if(event==='soul-scaffold') {
111
125
  // Souls are portable declarations, never an implicit knowledge store.
112
126
  result={meta:{scaffolded:false},brief:'OKF requires explicit external bindings and soul/okf.json before a working instance can spawn. Use init or migrate; no knowledge was created in this soul.'};
113
127
  } else if(event==='spawn') {
@@ -115,7 +129,8 @@ else {
115
129
  if(s.skipped) result={meta:{memory:'none'},brief:'Service agent: follow your own task; no working-memory upkeep.'};
116
130
  else {
117
131
  const schedule=loadStatus(s).schedule.result;
118
- result={meta:{memory:'okf-v2',source:s.file,schedule},brief:`Knowledge is an immutable accepted snapshot at ./knowledge/. Read knowledge/view.json for base paths under knowledge/bases/<alias>/, then the indexes for ${[...new Set([...s.decl.owns,...s.decl.reads])].join(', ')}. Follow only relevant links. All configured bases are available. Use oats okf read for current accepted text; old views stay stable. Keep STATE.md/log.md/notes/ current; never edit knowledge.`};
132
+ const nodes=(list)=>list.join(', ') || 'none';
133
+ result={meta:{memory:'okf-v2',source:s.file,schedule},brief:`Your knowledge is read remotely at its accepted state; there is no local copy. Start every task with \`oats okf index\` (owns: ${nodes(s.decl.owns)}; reads: ${nodes(s.decl.reads.filter(r=>!s.decl.owns.includes(r)))}), then \`oats okf cat --base ALIAS PATH\` for the concepts the task needs; \`oats okf search\` before re-deriving a decision. Load the okf-consultation skill for the procedure. Keep STATE.md/log.md/notes/ current; never edit knowledge.`};
119
134
  }
120
135
  } else if(event==='retire') {
121
136
  if(captured) {
@@ -151,20 +166,6 @@ else {
151
166
  if(flags.enable || flags.disable) {oats(['schedule',flags.enable?'enable':'disable',`okf-${s.id}`,'--dir',s.context,'--json'],s.context);updateStatus(s,current=>{current.auto=!!flags.enable;});}
152
167
  if(flags['install-host']) oats(['schedule','host','install','--dir',s.context,'--json'],s.context);
153
168
  result={source:s.file,scheduler:oats(['schedule','list','--dir',s.context,'--json'],s.context).scheduler};
154
- } else if(event==='read' || event==='refresh') {
155
- const s=src();
156
- // A descriptor-selected read is independent of any invoking/source home.
157
- // In particular, retired sources must not leave caches in context/repo.
158
- const target=join(flags.source?join(dirname(s.file),'views'):home,`knowledge-view-${randomUUID()}`);
159
- const receipts=views(s.bindings,s.decl,target);
160
- if(event==='refresh') result={path:target,receipts};
161
- else {
162
- if(!Object.hasOwn(s.bindings.bases,flags.base || '')) fail('E_CONFIG','unknown --base');
163
- const basePath=join(target,receipts[flags.base].path);
164
- const p=safePath(join(basePath,flags.path || 'index.md'));
165
- if(!p.startsWith(basePath+'/') || !p.endsWith('.md')) fail('E_PATH','read only contained Markdown');
166
- result={path:p,text:fs.readFileSync(p,'utf8'),receipt:receipts[flags.base]};
167
- }
168
169
  } else if(event==='init') result=initBase(loadBindings(),flags.base,flags.nodes,flags.output,{confirm:!!flags.confirm});
169
170
  else if(event==='migrate') {
170
171
  if(flags['source-home']) result=migrateSource(loadBindings(),flags['source-home']);
@@ -176,6 +177,10 @@ else {
176
177
  else fail('E_USAGE',`unknown command ${event}; see --help`);
177
178
  answer=hook?result:{schemaVersion:1,ok:true,result};
178
179
  } catch(e) {const code=e.code || 'E_OKF';exit=1;answer=hook?{meta:{...(event==='retire'?{retired:false,reason:e.message}:{})},warning:`oats-okf ${code}: ${e.message}`}:{schemaVersion:1,ok:false,error:{code,message:e.message}};}
179
- // Let Node drain the pipe; no process.exit after a possibly large view.
180
- process.stdout.write(JSON.stringify(answer)+'\n');process.exitCode=exit;
180
+ // Consult commands print text unless --json; every other answer is JSON.
181
+ // Let Node drain the pipe; no process.exit after a possibly large answer.
182
+ if(textMode && exit) process.stderr.write(`oats okf ${event}: ${answer.error.code}: ${answer.error.message}\n`);
183
+ else if(textMode) process.stdout.write(text+'\n');
184
+ else process.stdout.write(JSON.stringify(answer)+'\n');
185
+ process.exitCode=exit;
181
186
  }