@awebai/oats 0.24.1 → 0.24.2

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.
package/bin/oats.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * The kernel resolves per-key closest-wins from wherever agents actually run,
14
14
  * so binding at a level scopes the capability to everything under it.
15
15
  */
16
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
16
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
18
  import { homedir, tmpdir } from "node:os";
19
19
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
@@ -26,10 +26,10 @@ import {
26
26
  capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath, activateCapturedScaffold, loadCapturedDispatch, inspectPortableOnboarding, prepareCapturedComposition, resolveCapturedHelper, capturedNativeSessionAvailability, scaffoldCapturedInstance, startCapturedInstanceSession, withCapturedBindingFile, withCapturedInvocationContextFile,
27
27
  readCapabilityLocks, writeCapabilityLock, admitCapturedAction, beginCapturedIntent, settleCapturedIntent,
28
28
  parsePackageSource, inspectGitSourceRoot, acquirePackage, restorePackages, listInstalledPackages, readPackageLocks, readLockedConfigTemplates,
29
- officialCapabilityPackage, officialPackageCatalog,
29
+ officialCapabilityPackage, officialPackageCatalog, DEFAULT_PACKAGE_PATH,
30
30
  approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
- resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
32
+ resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
33
33
  findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
@@ -53,11 +53,13 @@ import { readPortablePreparationRequest } from "../lib/portable-onboarding-reque
53
53
  import { portableScope } from "../lib/portable-state.mjs";
54
54
  import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../lib/captured-operation-process.mjs";
55
55
  import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
56
+ import { loadSetupExpertEdition, SETUP_EXPERT, SETUP_CAPABILITIES } from "../lib/setup-expert-source.mjs";
57
+ import { parsePortableSource } from "../lib/source-spec.mjs";
56
58
 
57
59
  const args = process.argv.slice(2);
58
60
  let cmd = args[0];
59
61
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
60
- const KERNEL_COMMANDS = new Set(["prepare", "capture", "config", "create", "doctor", "inspect", "operation", "soul", "launch-config", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
62
+ const KERNEL_COMMANDS = new Set(["prepare", "capture", "config", "create", "doctor", "inspect", "operation", "soul", "launch-config", "experimental", "onboard", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
61
63
  const flag = (name) => {
62
64
  const i = args.indexOf(`--${name}`);
63
65
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -385,6 +387,9 @@ function capturedCommand(selector) {
385
387
  const selected = helperSelection?.executionBinding ?? target;
386
388
  const loaded = loadCapturedDispatch({ deployment: selected.deployment, resolution: selected.resolution, action: { kind: args.includes("--composition") ? "compose" : "inspect" } });
387
389
  const result = { resolution: loaded.resolution, capture: loaded.record.capture, nativeSession: capturedNativeSessionAvailability(),
390
+ launchSelection: loaded.record.dispatch.launch === null ? null : {
391
+ runtime: loaded.record.dispatch.launch.runtime, model: loaded.record.dispatch.launch.model,
392
+ },
388
393
  ...(helperSelection ? { helperSelection } : {}),
389
394
  capabilities: [...loaded.capabilities.values()].map(({ id, manifest }) => ({ id, version: manifest.version,
390
395
  approval: loaded.approvals.find((entry) => entry.artifact.capability === id).status })),
@@ -505,6 +510,10 @@ function resolveForDoctor(ctx, soulName, { json } = {}) {
505
510
  die(`${e.message}`);
506
511
  }
507
512
  }
513
+ function operationalKnowledgeNote(composition, soulName) {
514
+ return composition && !composition.oatsCoreDeclared
515
+ ? `soul ${soulName} has no oats.core capability; kernel-shipped operational skills are deprecated` : null;
516
+ }
508
517
  function doctorComposition(ctx, soulName) {
509
518
  if (!soulName) return undefined;
510
519
  const root = findRoot(ctx);
@@ -1259,7 +1268,8 @@ function doctorJson(dir) {
1259
1268
  hooks: Object.keys(r.layers[l].hooks || {}), missingRequires: r.layers[l].missingRequires,
1260
1269
  provenance: r.provenance[l],
1261
1270
  } : { provenance: r.provenance[l] || null }])),
1262
- kernelInjection: r.kernelInjection,
1271
+ kernelInjection: composition?.resolved.kernelInjection ?? r.kernelInjection,
1272
+ information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1263
1273
  injects: r.injects,
1264
1274
  capabilities: r.capabilities.map((c) => ({ id: c.id, layer: c.layer, command: c.command, origin: c.origin, provenance: c.provenance, settings: c.settings, skills: c.skills, inject: c.inject, hooks: Object.keys(c.hooks || {}), trust: c.trust })),
1265
1275
  acquired: Object.fromEntries(Object.entries(mans).map(([n, m]) => [n, { layer: m.layer, command: m.command, version: m.version, dir: m._dir, origin: m._origin, description: m.description }])),
@@ -1288,6 +1298,7 @@ function doctor(dir) {
1288
1298
  const soulName = flag("soul");
1289
1299
  const chain = configChain(ctx);
1290
1300
  const r = resolveForDoctor(ctx, soulName);
1301
+ const composition = doctorComposition(ctx, soulName);
1291
1302
  console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1292
1303
 
1293
1304
  // Kernel/bridge version skew (published in lockstep from one tag).
@@ -1332,7 +1343,8 @@ function doctor(dir) {
1332
1343
  }
1333
1344
 
1334
1345
  console.log("\nKernel injection:");
1335
- console.log(` oats: ${r.kernelInjection?.inject ? shortPath(r.kernelInjection.inject) : "none"} [${r.kernelInjection?.provenance || "default"}]`);
1346
+ const kernelInjection = composition?.resolved.kernelInjection ?? r.kernelInjection;
1347
+ console.log(` oats: ${kernelInjection?.inject ? shortPath(kernelInjection.inject) : "none"} [${kernelInjection?.provenance || "default"}]`);
1336
1348
 
1337
1349
  console.log("\nUnconditional injections (outermost→innermost):");
1338
1350
  if (r.injects.length === 0) console.log(" (none)");
@@ -1440,7 +1452,8 @@ function doctor(dir) {
1440
1452
  }
1441
1453
 
1442
1454
  if (soulName) {
1443
- const composition = doctorComposition(ctx, soulName);
1455
+ const information = operationalKnowledgeNote(composition, soulName);
1456
+ if (information) console.log(`\nINFO: ${information}`);
1444
1457
  console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
1445
1458
  } else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
1446
1459
  }
@@ -3928,16 +3941,17 @@ function spawnCmd() {
3928
3941
  if (!agent && !instrFile && !defFile) {
3929
3942
  const def = listAgentDefs(process.cwd()).find((d) => d.name === name);
3930
3943
  if (!def) bail("E_UNKNOWN_AGENT", `unknown agent "${name}" (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"}; importable defs: ${listAgentDefs(process.cwd()).map((d) => d.name).join(", ") || "none"}) — pass --instructions-file or --def-file to create a local agent`);
3931
- agent = upsertLocalAgent(root, { name: def.name, file: def.path, repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model") });
3944
+ agent = upsertLocalAgent(root, { name: def.name, file: def.path, repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"), oatsCore: !args.includes("--no-oats-core") });
3932
3945
  } else if (!agent || agent.kind === "local") {
3933
3946
  agent = upsertLocalAgent(root, {
3934
- name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
3947
+ name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined, oatsCore: !args.includes("--no-oats-core"),
3935
3948
  repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo: yoloFlag(),
3936
3949
  });
3937
3950
  } else {
3938
3951
  bail("E_BAD_ARGS", `"${name}" is a persistent agent — spawn it without --instructions-file/--def-file`);
3939
3952
  }
3940
3953
  }
3954
+ for (const information of agent.notes || []) note(`[${information.code}] ${information.message}`);
3941
3955
  // Lineage is explicit: --relation child|sibling|parent|unrelated anchors the new
3942
3956
  // instance to --relative-to <instance>. --parent X is sugar for
3943
3957
  // --relative-to X --relation child (agents spawning sub-agents pass their own
@@ -4221,10 +4235,134 @@ async function paneCmd() {
4221
4235
  die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
4222
4236
  }
4223
4237
 
4238
+ function onboardCmd() {
4239
+ const fail = (code, message, details) => JSON_MODE ? jsonFail(code, message, details) : die(message);
4240
+ const values = new Map();
4241
+ for (let i = 1; i < args.length; i++) {
4242
+ const arg = args[i];
4243
+ if (["--json", "--force-existing"].includes(arg)) { values.set(arg.slice(2), true); continue; }
4244
+ if (!["--dir", "--workspace"].includes(arg) || values.has(arg.slice(2)) || !args[i + 1] || args[i + 1].startsWith("--")) {
4245
+ fail("E_BAD_ARGS", "usage: oats onboard [--dir <deployment>] [--workspace <git:source[@revision]>] [--force-existing] [--json]");
4246
+ }
4247
+ values.set(arg.slice(2), args[++i]);
4248
+ }
4249
+ dropAmbientRoot();
4250
+ let deployment, root, acquired, created, configFile, configBefore, configWritten;
4251
+ try {
4252
+ // Like create: an existing enclosing roster wins, otherwise bootstrap at
4253
+ // the enclosing Git root or explicit directory. Canonicalize existing parents.
4254
+ const requested = resolve(values.get("dir") || process.cwd()), missing = [];
4255
+ let parent = requested;
4256
+ while (!existsSync(parent)) { missing.unshift(basename(parent)); parent = dirname(parent); }
4257
+ const start = join(realpathSync(parent), ...missing);
4258
+ root = findRoot(start) || join(defaultRepo(start) || start, "agents");
4259
+ deployment = dirname(root);
4260
+ assertNoSymlinkedParents(deployment, root, "onboard agents root");
4261
+ assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4262
+ const assertSetupAbsent = () => {
4263
+ for (const candidate of [join(deployment, "local-agents", SETUP_EXPERT), join(root, SETUP_EXPERT), join(root, "local-agents", SETUP_EXPERT), join(root, "tmp-agents", SETUP_EXPERT)]) {
4264
+ let present = false;
4265
+ try { lstatSync(candidate); present = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
4266
+ if (present) throw Object.assign(new Error(`existing or incomplete setup expert is preserved at ${candidate}; it will not be overwritten`), { code: "E_AGENT_EXISTS" });
4267
+ }
4268
+ };
4269
+ assertSetupAbsent();
4270
+ const agents = listAgents(root);
4271
+ if (agents.length && !values.get("force-existing")) throw Object.assign(new Error("deployment already has agents; use --force-existing to add the setup expert without replacing them"), { code: "E_DEPLOYMENT_NOT_EMPTY" });
4272
+ if (findAgent(root, SETUP_EXPERT)) throw Object.assign(new Error("oats-setup-expert already exists; it will not be overwritten"), { code: "E_AGENT_EXISTS" });
4273
+ const edition = loadSetupExpertEdition(values.get("workspace"));
4274
+ for (const key of ["description", "runtime", "model"]) if (edition.declaration[key] !== undefined) assertSafeConfigValue(edition.declaration[key], `setup edition ${key}`);
4275
+ const catalog = officialPackageCatalog(), entry = catalog["oats.framework"];
4276
+ if (!Object.hasOwn(catalog, "oats.framework") || !entry?.url || !entry.ref
4277
+ || SETUP_CAPABILITIES.some(id => { const m = officialCapabilityPackage(id); return !m.available || m.package !== "oats.framework" || m.migratedCapability !== id; })) {
4278
+ throw Object.assign(new Error("official oats.framework with core/setup aliases and a published revision is required"), { code: "needs-configuration" });
4279
+ }
4280
+ const catalogSource = parsePortableSource(`git:${entry.url}@${entry.ref}#${entry.path ?? DEFAULT_PACKAGE_PATH}`);
4281
+ const file = join(deployment, "oats-config.yaml");
4282
+ if (existsSync(file) && !lstatSync(file).isFile()) throw Object.assign(new Error("onboard will not replace a non-regular deployment configuration"), { code: "E_CONFIG_BROKEN" });
4283
+ const before = existsSync(file) ? readFileSync(file, "utf8") : null;
4284
+ configFile = file; configBefore = before;
4285
+ const caps = readCapabilitiesModel(file), previous = resolveOatsConfig(deployment, SETUP_EXPERT);
4286
+ // Exclusions are for the NEW soul only. Never turn off an existing root's
4287
+ // global provider/layer just to make bootstrap work under --force-existing.
4288
+ for (const cap of previous.capabilities) {
4289
+ if (SETUP_CAPABILITIES.includes(cap.id)) continue;
4290
+ let target;
4291
+ if (cap.layer) {
4292
+ const existing = caps.layers[cap.layer];
4293
+ if (existing && (existing === "none" || existing.capability !== cap.id)) throw Object.assign(new Error(`cannot safely exclude ${cap.id} for the setup soul at this level; choose a fresh deployment`), { code: "needs-configuration" });
4294
+ target = caps.layers[cap.layer] ||= { capability: cap.id };
4295
+ } else target = caps.additive[cap.id] ||= {};
4296
+ target.souls = { ...target.souls, [SETUP_EXPERT]: false };
4297
+ }
4298
+ if (before === null && !agents.length) for (const layer of LAYERS) caps.layers[layer] ??= "none";
4299
+ for (const id of SETUP_CAPABILITIES) {
4300
+ const target = caps.additive[id] ||= {};
4301
+ if (target.from && target.from !== "installed") throw Object.assign(new Error(`${id} already selects another provenance; choose a fresh deployment`), { code: "needs-configuration" });
4302
+ target.from = "installed"; target.souls = { ...target.souls, [SETUP_EXPERT]: true };
4303
+ }
4304
+ const text = replaceCapabilitiesBlock(before ?? `name: ${scaffoldConfigName(deployment)}\n`, caps);
4305
+ mkdirSync(deployment, { recursive: true });
4306
+ acquired = acquirePackage(deployment, "oats.framework", { expectPackage: "oats.framework",
4307
+ catalog(id, selector) { const selected = Object.hasOwn(catalog, id) ? catalog[id] : null; return selected?.url ? { url: selected.url, ref: selector || selected.ref, path: selected.path } : undefined; },
4308
+ assertCommittable(plan) {
4309
+ const pkg = plan.packages.find(p => p.package === "oats.framework");
4310
+ if (edition.packageIntegrity && pkg?.integrity !== edition.packageIntegrity) throw Object.assign(new Error("selected edition's same-repository package differs from the official acquisition; align the reviewed source and catalog explicitly"), { code: "integrity-drift" });
4311
+ for (const id of SETUP_CAPABILITIES) {
4312
+ const cap = plan.capabilities.find(c => c.capability === id);
4313
+ if (!cap || cap.package !== "oats.framework" || cap.layer || Object.values(cap.executableSurface || {}).some(value => Array.isArray(value) && value.length)) {
4314
+ throw Object.assign(new Error(`setup bootstrap needs resources-only ${id}; executable surfaces require a separate explicit approval path`), { code: "approval-required" });
4315
+ }
4316
+ }
4317
+ } });
4318
+ if ((existsSync(file) ? readFileSync(file, "utf8") : null) !== before) throw Object.assign(new Error("deployment configuration changed during acquisition; nothing was activated"), { code: "E_CONFIG_CHANGED" });
4319
+ writeFileAtomic(file, text); configWritten = text;
4320
+ const selected = resolveOatsConfig(deployment, SETUP_EXPERT);
4321
+ if (selected.capabilities.length !== 2 || SETUP_CAPABILITIES.some(id => !selected.capabilities.some(c => c.id === id))) {
4322
+ throw Object.assign(new Error("setup expert's effective configuration contains other capabilities; no soul or hook was created"), { code: "needs-configuration" });
4323
+ }
4324
+ // Required capabilities were checked resources-only before acquisition: no
4325
+ // unrelated or executable soul-scaffold hooks can run during local creation.
4326
+ assertNoSymlinkedParents(deployment, root, "onboard agents root");
4327
+ assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4328
+ assertSetupAbsent();
4329
+ mkdirSync(root, { recursive: true });
4330
+ created = coreCreateAgent(root, { name: SETUP_EXPERT, local: true, oatsCore: false, repo: deployment, work: "directory",
4331
+ runtime: edition.declaration.runtime, model: edition.declaration.model, yolo: false,
4332
+ description: edition.declaration.description, instructions: edition.instructions });
4333
+ const pkg = acquired.installed.find(p => p.package === "oats.framework");
4334
+ const source = parsePortableSource(`git:${catalogSource.url}@${pkg.commit}#${pkg.path}`);
4335
+ const requires = { capabilities: Object.fromEntries(SETUP_CAPABILITIES.map(id => [id, { source: `${source.source}#${source.path}` }])) };
4336
+ const soulFile = join(created.soul, "soul.yaml");
4337
+ writeFileAtomic(soulFile, readFileSync(soulFile, "utf8") + `requires: ${JSON.stringify(requires)}\ndefaults: ${JSON.stringify(edition.declaration.defaults)}\n`);
4338
+ const agent = findAgent(root, SETUP_EXPERT), composition = composeInstanceAgentsMd(created.soul, deployment, SETUP_EXPERT, "directory", "local");
4339
+ planInstanceResources({ resolved: composition.resolved, soulDir: created.soul, agent, contextDir: deployment, composition });
4340
+ const argv = [process.execPath, CLI_BIN, "spawn", SETUP_EXPERT, "--dir", deployment, "--no-yolo", "--task", "Help me configure this deployment and adopt my workspace with explicit approvals."];
4341
+ const result = { mode: "classic", captured: false, deployment, agentsRoot: root, ...created, source: edition.source,
4342
+ package: { id: pkg.package, version: pkg.version, commit: pkg.commit, path: pkg.path }, lockFile: acquired.lockFile,
4343
+ capabilities: [...SETUP_CAPABILITIES], launched: false, next: { argv, command: argv.map(shellQuote).join(" ") } };
4344
+ if (JSON_MODE) jsonOk(result);
4345
+ else { console.log(`Created local ${SETUP_EXPERT} in ${deployment} (classic bootstrap, not captured preparation).`); console.log(`No model was launched. Next:\n${result.next.command}`); }
4346
+ } catch (error) {
4347
+ // Roll back only configuration bytes still exactly owned by this attempt.
4348
+ // Acquired artifacts/locks and any incomplete new soul remain visible evidence.
4349
+ let configRestored = false;
4350
+ if (configWritten !== undefined) {
4351
+ try {
4352
+ if (lstatSync(configFile).isFile() && readFileSync(configFile, "utf8") === configWritten) {
4353
+ if (configBefore === null) rmSync(configFile); else writeFileAtomic(configFile, configBefore);
4354
+ configRestored = true;
4355
+ }
4356
+ } catch { /* never erase another writer's change or hide a failed rollback */ }
4357
+ }
4358
+ fail(error.code || "E_ONBOARD_FAILED", error.message, { deployment, agentsRoot: root, packageAcquired: !!acquired, soul: created?.soul, configRestored, launched: false });
4359
+ }
4360
+ }
4361
+
4224
4362
  function createCmd() {
4225
4363
  const yolo = yoloFlag();
4226
4364
  const name = args[1];
4227
- if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
4365
+ if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--no-oats-core] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
4228
4366
  const local = args.includes("--local");
4229
4367
  const startDir = dirFlag();
4230
4368
  // `create` BOOTSTRAPS a deployment: with no agents/ or local-agents/ yet,
@@ -4243,11 +4381,12 @@ function createCmd() {
4243
4381
  }
4244
4382
  const instrFile = flag("instructions-file");
4245
4383
  const r = coreCreateAgent(root, {
4246
- name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || (flag("work") === "directory" ? undefined : defaultRepo(process.cwd())),
4384
+ name, local, oatsCore: !args.includes("--no-oats-core"), description: flag("description"), type: flag("type"), repo: flag("repo") || (flag("work") === "directory" ? undefined : defaultRepo(process.cwd())),
4247
4385
  work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo,
4248
4386
  instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
4249
4387
  });
4250
4388
  if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
4389
+ for (const information of r.notes || []) console.error(`[${information.code}] ${information.message}`);
4251
4390
  if (bootstrapped) console.log(`Created deployment root ${shortPath(root)} (this scope had no agents/ yet)`);
4252
4391
  console.log(`Created ${r.kind === "local" ? "LOCAL agent (uncommitted — soul lives in local-agents/, gitignored)" : "agent"} "${r.agent}" — soul at ${shortPath(r.soul)}`);
4253
4392
  console.log(`Edit ${shortPath(join(r.soul, "AGENTS.md"))} to define its role, then: oats spawn ${r.agent} --task "..."`);
@@ -4900,6 +5039,14 @@ if (cmd === "prepare" || captured?.args[0] === "prepare") {
4900
5039
  if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
4901
5040
  prepareCmd(); process.exit(0);
4902
5041
  }
5042
+ if (cmd === "onboard" || captured?.args[0] === "onboard") {
5043
+ if (captured) {
5044
+ if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit classic bootstrap and cannot use captured selectors");
5045
+ die("onboard is explicit classic bootstrap and cannot use captured selectors");
5046
+ }
5047
+ if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
5048
+ onboardCmd(); process.exit(0);
5049
+ }
4903
5050
  // Other commands retain their existing explicit/inherited selection rules.
4904
5051
  try { captured ??= capturedSelector(args); }
4905
5052
  catch (error) {
@@ -5028,7 +5175,10 @@ Usage:
5028
5175
  --instance <name> | --home <abs> attachments over its saved route (bytes stream on
5029
5176
  --file <path> [--json] ssh stdin; sha256 verified); the server must
5030
5177
  advertise session-upload (oats 0.22.13 or later)
5031
- oats create <name> [--local] create an agent soul; --local = full
5178
+ oats onboard [--dir <deployment>] bootstrap a LOCAL setup expert from official
5179
+ [--workspace <git:source[@revision]>] capabilities; classic path, not captured prepare;
5180
+ [--force-existing] [--json] prints the next spawn command, never launches
5181
+ oats create <name> [--local] [--no-oats-core] create an agent soul; --local = full
5032
5182
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
5033
5183
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
5034
5184
  [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
@@ -5058,6 +5208,7 @@ Usage:
5058
5208
  oats spawn <agent> [--task <text>] spawn an instance (tmux/Herdr; --no-launch
5059
5209
  [--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
5060
5210
  [--parent <instance>] --def-file creates a local agent;
5211
+ [--no-oats-core] omit the default only on NEW local souls;
5061
5212
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
5062
5213
  [--relative-to <instance>] new instance to an existing one; --parent X
5063
5214
  [--relative-root <agents-root>] disambiguates same-named team anchors
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { runBindingWire } from '../lib/binding-wire.mjs';
3
+
4
+ const args=process.argv.slice(2);
5
+ if(args.includes('--help') || args.includes('-h')) {
6
+ process.stdout.write('oats aweb provider binding phase (manifest-owned JSON stdin/stdout)\n');
7
+ } else {
8
+ const phase=args[0];
9
+ if(args.length!==1) await runBindingWire(phase,[],process.stdout);
10
+ else await runBindingWire(phase);
11
+ }
@@ -40,6 +40,10 @@ import { execFileSync } from "node:child_process";
40
40
  import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
41
41
  import { hostname } from "node:os";
42
42
  import { join, dirname, resolve, delimiter } from "node:path";
43
+ import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
44
+ import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
45
+ import { runCapturedNative } from "../lib/captured-native.mjs";
46
+ import { parseBindingJson } from "../lib/binding-wire.mjs";
43
47
 
44
48
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
45
49
  * names and invite tokens all flow through here; quoting them correctly is a
@@ -110,6 +114,28 @@ const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
110
114
  * state already exists (e.g. a joined identity) so retire can undo it. */
111
115
  const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(m).slice(0, 300)}` }, 1);
112
116
 
117
+ // Any selected snapshot enters the captured consumer BEFORE legacy settings,
118
+ // root discovery or identity handling. Invalid-present never falls back.
119
+ try {
120
+ const loaded = loadCapturedAwebExecution();
121
+ if (loaded.kind === "captured") {
122
+ const event = process.argv[2] || process.env.OATS_EVENT;
123
+ if ((process.env.OATS_EVENT && process.env.OATS_EVENT !== event) || process.argv.slice(3).some(arg => arg !== "--json")) throw new Error("captured entrypoint arguments differ from the selected action");
124
+ const manifest = JSON.parse(readFileSync(new URL("../oats.json", import.meta.url), "utf8"));
125
+ const selected = requireCapturedAwebAction(loaded, event, manifest);
126
+ const settings = parseBindingJson(Buffer.from(process.env.OATS_SETTINGS || "{}"));
127
+ if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => k !== "delivery")) throw new Error("captured settings support delivery only; no identity copying or ambient fallback");
128
+ const checked = assessCapturedSessionReadiness({ binding: selected.binding, invocation: selected.context, settings }, {
129
+ query(args, options) { selected.assertCurrent(); const result = querySelectedKernel(args, options); selected.assertCurrent(); return result; },
130
+ });
131
+ if (checked.status !== "ready") out({ ...checked, warning: `oats-aweb: ${checked.problems[0].message}` }, 1);
132
+ const result = runCapturedNative({ selected, event, settings, run });
133
+ out(result.output, result.exitCode);
134
+ }
135
+ } catch (error) {
136
+ out({ status: "needs-configuration", problems: [{ code: "invalid-binding", message: "invalid or changed captured aweb execution input" }], warning: "oats-aweb: invalid or changed captured aweb execution input; no legacy fallback was used" }, 1);
137
+ }
138
+
113
139
  const event = process.env.OATS_EVENT || process.argv[2];
114
140
  const instance = process.env.OATS_INSTANCE;
115
141
  const home = process.env.OATS_HOME || process.cwd();
@@ -0,0 +1,214 @@
1
+ import { TextDecoder } from 'node:util';
2
+ import { assessCapturedSessionReadiness } from './session-readiness.mjs';
3
+ import {
4
+ MESSAGING_CONTRACT,
5
+ MESSAGING_CONTRACT_VERSION,
6
+ bindMessagingDomain,
7
+ normalizeMessagingDeclarations,
8
+ validateAwebBinding,
9
+ validateAwebInvocationContext,
10
+ } from './portable-binding.mjs';
11
+
12
+ export const BINDING_WIRE_LIMITS=Object.freeze({bytes:1024*1024,depth:32,entries:16384});
13
+ const CAPABILITY='oats.aweb',SLOT='messaging';
14
+ const phases=new Set(['normalize','bind','check']);
15
+ const declarationKinds=new Set(['soul','workspace','adoption','operator']);
16
+ const errorCodes=new Set(['needs-configuration','requirement-conflict','invalid-binding','authorization-required','host-requirement-missing','provider-unavailable','provider-not-qualified']);
17
+ const obj=value=>value!==null && typeof value==='object' && !Array.isArray(value);
18
+ const wireError=code=>{throw Object.assign(new Error(code),{wireCode:code});};
19
+ const canonical=value=>value===null || typeof value!=='object'?JSON.stringify(value):Array.isArray(value)?`[${value.map(canonical).join(',')}]`:`{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
20
+ const same=(a,b)=>canonical(a)===canonical(b);
21
+ function keys(value,allowed,required) {
22
+ if(!obj(value)) wireError('invalid-binding');
23
+ for(const key of Object.keys(value)) if(!allowed.includes(key)) wireError('invalid-binding');
24
+ for(const key of required) if(!Object.hasOwn(value,key)) wireError('invalid-binding');
25
+ return value;
26
+ }
27
+ function pointer(value) {return typeof value==='string' && /^(?:\/(?:[^~]|~[01])*)+$/.test(value);}
28
+ function bindingKey(value) {return pointer(value) && value.startsWith('/bindings/messaging/') && value.length>'/bindings/messaging/'.length;}
29
+
30
+ class StrictJsonParser {
31
+ constructor(text,{depth,entries}) {this.text=text;this.maxDepth=depth;this.maxEntries=entries;this.at=0;this.entries=0;}
32
+ whitespace() {while(/[\u0009\u000a\u000d\u0020]/.test(this.text[this.at] || '')) this.at++;}
33
+ count() {if(++this.entries>this.maxEntries) wireError('invalid-binding');}
34
+ string() {
35
+ if(this.text[this.at]!=='"') wireError('invalid-binding');
36
+ const start=this.at++;
37
+ while(this.at<this.text.length) {
38
+ const code=this.text.charCodeAt(this.at++);
39
+ if(code===0x22) {try{return JSON.parse(this.text.slice(start,this.at));}catch{wireError('invalid-binding');}}
40
+ if(code<0x20) wireError('invalid-binding');
41
+ if(code===0x5c) {
42
+ const escaped=this.text[this.at++];
43
+ if(escaped==='u') {if(!/^[0-9a-fA-F]{4}$/.test(this.text.slice(this.at,this.at+4))) wireError('invalid-binding');this.at+=4;}
44
+ else if(!'"\\/bfnrt'.includes(escaped || '')) wireError('invalid-binding');
45
+ }
46
+ }
47
+ wireError('invalid-binding');
48
+ }
49
+ value(depth=1) {
50
+ this.whitespace();const char=this.text[this.at];
51
+ if(char==='"') return this.string();
52
+ if(char==='{') {
53
+ if(depth>this.maxDepth) wireError('invalid-binding');this.at++;this.whitespace();
54
+ const result=Object.create(null),seen=new Set();if(this.text[this.at]==='}') {this.at++;return result;}
55
+ while(true) {
56
+ this.whitespace();const key=this.string();if(seen.has(key)) wireError('invalid-binding');seen.add(key);this.count();
57
+ this.whitespace();if(this.text[this.at++]!==':') wireError('invalid-binding');result[key]=this.value(depth+1);this.whitespace();
58
+ const next=this.text[this.at++];if(next==='}') return result;if(next!==',') wireError('invalid-binding');
59
+ }
60
+ }
61
+ if(char==='[') {
62
+ if(depth>this.maxDepth) wireError('invalid-binding');this.at++;this.whitespace();
63
+ const result=[];if(this.text[this.at]===']') {this.at++;return result;}
64
+ while(true) {this.count();result.push(this.value(depth+1));this.whitespace();const next=this.text[this.at++];if(next===']') return result;if(next!==',') wireError('invalid-binding');}
65
+ }
66
+ for(const [token,value] of [['true',true],['false',false],['null',null]]) if(this.text.startsWith(token,this.at)) {this.at+=token.length;return value;}
67
+ const match=/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.at));
68
+ if(!match) wireError('invalid-binding');this.at+=match[0].length;const number=Number(match[0]);if(!Number.isFinite(number)) wireError('invalid-binding');return number;
69
+ }
70
+ parse() {this.whitespace();const value=this.value();this.whitespace();if(this.at!==this.text.length) wireError('invalid-binding');return value;}
71
+ }
72
+
73
+ export function parseBindingJson(bytes,limits=BINDING_WIRE_LIMITS) {
74
+ if(!Buffer.isBuffer(bytes)) bytes=Buffer.from(bytes);
75
+ if(bytes.length>limits.bytes) wireError('invalid-binding');
76
+ let text;try{text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);}catch{wireError('invalid-binding');}
77
+ return new StrictJsonParser(text,limits).parse();
78
+ }
79
+ function settings(value) {
80
+ if(!obj(value)) wireError('invalid-binding');
81
+ if(Object.hasOwn(value,'identity')) wireError('provider-not-qualified');
82
+ keys(value,['delivery'],[]);
83
+ if(value.delivery!==undefined && !['channel','session'].includes(value.delivery)) wireError('needs-configuration');
84
+ return value;
85
+ }
86
+ function request(value,phase) {
87
+ keys(value,['schemaVersion','phase','slot','capability','settings','input'],['schemaVersion','phase','slot','capability','settings','input']);
88
+ if(value.schemaVersion!==1 || value.phase!==phase || value.slot!==SLOT || value.capability!==CAPABILITY) wireError('invalid-binding');
89
+ settings(value.settings);return value;
90
+ }
91
+ function declaration(value) {
92
+ keys(value,['kind','value','origin','origins'],['kind','value','origin','origins']);
93
+ if(!declarationKinds.has(value.kind) || !obj(value.value) || !obj(value.origin) || !obj(value.origins)) wireError('invalid-binding');
94
+ return value;
95
+ }
96
+ function context(value,{request=false}={}) {
97
+ if(value?.kind==='workspace') {
98
+ keys(value,request?['kind','identity','observation']:['kind','identity'],request?['kind','identity','observation']:['kind','identity']);
99
+ if(!obj(value.identity) || (request && !obj(value.observation))) wireError('invalid-binding');
100
+ return {kind:'workspace',identity:value.identity};
101
+ }
102
+ if(value?.kind==='standalone') {
103
+ keys(value,['kind','key'],['kind','key']);
104
+ if(value.key!==null && (typeof value.key!=='string' || !value.key.trim())) wireError('invalid-binding');
105
+ return {kind:'standalone',key:value.key};
106
+ }
107
+ wireError('invalid-binding');
108
+ }
109
+ function normalizePhase(req) {
110
+ keys(req.input,['declarations','context'],['declarations','context']);
111
+ if(!Array.isArray(req.input.declarations)) wireError('invalid-binding');
112
+ const declarations=req.input.declarations.map(declaration);context(req.input.context,{request:true});
113
+ return normalizeMessagingDeclarations({declarations,context:req.input.context});
114
+ }
115
+ function choiceMap(value,model) {
116
+ if(!obj(value)) wireError('invalid-binding');
117
+ const allowed=new Set(['/bindings/messaging/responsibleHuman','/bindings/messaging/privateTeam','/bindings/messaging/wider',...Object.values(model.teams)]);
118
+ for(const [key,choice] of Object.entries(value)) {
119
+ if(!bindingKey(key) || !allowed.has(key)) wireError('invalid-binding');
120
+ keys(choice,['value','selectedBy','constraints','considered'],['value','selectedBy','constraints','considered']);
121
+ if(!Array.isArray(choice.constraints) || !Array.isArray(choice.considered) || (choice.selectedBy!==null && !obj(choice.selectedBy))) wireError('invalid-binding');
122
+ }
123
+ return value;
124
+ }
125
+ function model(value) {
126
+ keys(value,['contract','version','context','contextOrigin','requested','requestedOrigins','aliases','aliasOrigins','teams','privatePolicy','privatePolicyOrigin'],['contract','version','context','contextOrigin','requested','requestedOrigins','aliases','aliasOrigins','teams','privatePolicy','privatePolicyOrigin']);
127
+ if(value.contract!==MESSAGING_CONTRACT || value.version!==MESSAGING_CONTRACT_VERSION || !Array.isArray(value.requested) || !obj(value.requestedOrigins) || !obj(value.aliases) || !obj(value.aliasOrigins) || !obj(value.teams) || typeof value.privatePolicy!=='boolean') wireError('invalid-binding');
128
+ context(value.context);if(value.contextOrigin!==null && !obj(value.contextOrigin)) wireError('invalid-binding');if(value.privatePolicyOrigin!==null && !obj(value.privatePolicyOrigin)) wireError('invalid-binding');
129
+ for(const key of Object.values(value.teams)) if(!bindingKey(key)) wireError('invalid-binding');
130
+ return value;
131
+ }
132
+ function bindPhase(req) {
133
+ keys(req.input,['model','choices','context'],['model','choices','context']);
134
+ const normalized=model(req.input.model),selectedContext=context(req.input.context,{request:true});
135
+ if(!same(selectedContext,normalized.context) || (selectedContext.kind==='workspace' && !same(req.input.context.observation,normalized.contextOrigin))) wireError('invalid-binding');
136
+ return bindMessagingDomain({model:normalized,choices:choiceMap(req.input.choices,normalized)});
137
+ }
138
+ function binding(value) {
139
+ keys(value,['schemaVersion','capability','payloadContract','payloadVersion','payload','credentialRefs','provenance'],['schemaVersion','capability','payloadContract','payloadVersion','payload','credentialRefs','provenance']);
140
+ if(value.schemaVersion!==1 || value.capability!==CAPABILITY || value.payloadContract!==MESSAGING_CONTRACT || value.payloadVersion!==MESSAGING_CONTRACT_VERSION || !obj(value.payload) || !obj(value.credentialRefs) || Object.keys(value.credentialRefs).length || !Array.isArray(value.provenance)) wireError('invalid-binding');
141
+ for(const origin of value.provenance) if(!obj(origin)) wireError('invalid-binding');
142
+ return value;
143
+ }
144
+ function checkResult(message) {return {status:'needs-configuration',problems:[{code:'needs-configuration',message}]};}
145
+ function checkPhase(req) {
146
+ keys(req.input,['binding','context','action','invocation'],['binding','context','action']);
147
+ if(!obj(req.input.action) || typeof req.input.action.kind!=='string') wireError('invalid-binding');
148
+ const current=validateAwebBinding(binding(req.input.binding)),selectedContext=context(req.input.context,{request:true});
149
+ if(!same(selectedContext,current.payload.context)) wireError('invalid-binding');
150
+ const invocation=Object.hasOwn(req.input,'invocation')?validateAwebInvocationContext(req.input.invocation,current,{context:req.input.context,action:req.input.action}):null;
151
+ if(['command','hook','operation'].includes(req.input.action.kind) && (!invocation || invocation.instance===null || invocation.intent===null)) return checkResult('an admitted captured instance intent is required for execution');
152
+ if(current.payload.privateTeam===null) return checkResult('an explicit private-team binding is required');
153
+ // Read-only public kernel observations, not an account/grant attestation.
154
+ // Native setup is performed only by the separately admitted execution path.
155
+ return assessCapturedSessionReadiness({binding:current,invocation,settings:req.settings});
156
+ }
157
+
158
+ export function handleBindingRequest(phase,value) {
159
+ if(!phases.has(phase)) wireError('invalid-binding');
160
+ const req=request(value,phase);
161
+ if(phase==='normalize') return normalizePhase(req);
162
+ if(phase==='bind') return bindPhase(req);
163
+ return checkPhase(req);
164
+ }
165
+ function response(phase,body) {return {schemaVersion:1,phase,slot:SLOT,capability:CAPABILITY,...body};}
166
+ function errorCode(error) {if(errorCodes.has(error?.wireCode)) return error.wireCode;if(errorCodes.has(error?.code)) return error.code;return 'invalid-binding';}
167
+ // Only these literal domain diagnostics may cross the wire. Never reflect a
168
+ // caught exception's dynamic alias/key/path, native stderr or credential text.
169
+ const safeReasons=new Map([
170
+ ['needs-configuration',[
171
+ 'messaging-enabled standalone preparation needs an explicit context key',
172
+ 'messaging binding needs one soul declaration',
173
+ 'messaging workspace must declare private: per-human',
174
+ 'an explicit responsible-human binding is required',
175
+ 'an explicit wider-membership consent list is required',
176
+ 'a selected wider-team binding is required',
177
+ 'a selected wider alias needs an explicit workspace team mapping',
178
+ ]],
179
+ ['requirement-conflict',['multiple soul messaging declarations','adoption team aliases have conflicting mappings']],
180
+ ]);
181
+ const fallbackReasons=Object.freeze({
182
+ 'needs-configuration':'messaging settings and explicit binding selections are required',
183
+ 'requirement-conflict':'messaging declarations contain incompatible requirements',
184
+ 'invalid-binding':'messaging input must match the supported binding contract',
185
+ 'authorization-required':'explicit native messaging authorization is required',
186
+ 'host-requirement-missing':'a required native messaging host resource is unavailable',
187
+ 'provider-unavailable':'the selected messaging provider is unavailable',
188
+ 'provider-not-qualified':'the requested messaging configuration is not qualified',
189
+ });
190
+ function errorProblem(error) {
191
+ const code=errorCode(error),message=safeReasons.get(code)?.find(literal=>literal===error?.message)??fallbackReasons[code];
192
+ return {code,message};
193
+ }
194
+ function enforceOutputLimits(value,depth=1,state={entries:0}) {
195
+ if(depth>BINDING_WIRE_LIMITS.depth) wireError('provider-not-qualified');
196
+ if(value===null || typeof value!=='object') return;
197
+ for(const child of Array.isArray(value)?value:Object.values(value)) {
198
+ if(++state.entries>BINDING_WIRE_LIMITS.entries) wireError('provider-not-qualified');
199
+ enforceOutputLimits(child,depth+1,state);
200
+ }
201
+ }
202
+ export async function runBindingWire(phase,input=process.stdin,output=process.stdout) {
203
+ let answer;
204
+ try {
205
+ const chunks=[];let length=0;
206
+ for await(const chunk of input) {const bytes=Buffer.from(chunk);length+=bytes.length;if(length>BINDING_WIRE_LIMITS.bytes) wireError('invalid-binding');chunks.push(bytes);}
207
+ const result=handleBindingRequest(phase,parseBindingJson(Buffer.concat(chunks,length)));
208
+ answer=response(phase,{ok:true,result});
209
+ } catch(error) {answer=response(phases.has(phase)?phase:'check',{ok:false,error:errorProblem(error)});}
210
+ let bytes;
211
+ try {enforceOutputLimits(answer);bytes=Buffer.from(JSON.stringify(answer)+'\n');if(bytes.length>BINDING_WIRE_LIMITS.bytes) wireError('provider-not-qualified');}
212
+ catch {bytes=Buffer.from(JSON.stringify(response(phases.has(phase)?phase:'check',{ok:false,error:{code:'provider-not-qualified',message:'messaging response exceeds the supported wire limits'}}))+'\n');}
213
+ output.write(bytes);return answer.ok;
214
+ }