@awebai/oats 0.24.12 → 0.25.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 (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
package/bin/oats.mjs CHANGED
@@ -3,43 +3,46 @@
3
3
  * oats — the OATS command line.
4
4
  *
5
5
  * oats doctor [dir] [--json] show the resolved config with origins
6
- * oats install <name|url|path> [...] acquire + exact-lock a capability
7
- * oats trust <capability> approve locked executable surfaces
8
- * oats use <capability> [...] activate/exclude for global/group/soul
9
- * oats init [--raw] create an oats-config.yaml here
6
+ * oats onboard [<dir>] --workspace <ref> realize a workspace here (oats-local.yaml +
7
+ * agents/), then sync
8
+ * oats sync [--dir <d>] [--json] discover the workspace, confirm membership,
9
+ * resolve packages, approve, write the lock
10
+ * oats package add|remove ... edit `packages:` in the workspace file
11
+ * oats workspace status membership table, packages, approval
12
+ * oats capabilities | oats souls every visible item of the workspace
10
13
  *
11
- * `use` and `init` edit the oats-config.yaml at the detected level root:
12
- * cwd is your home dir → laptop; cwd has .git → repo; otherwise → workspace.
13
- * The kernel resolves per-key closest-wins from wherever agents actually run,
14
- * so binding at a level scopes the capability to everything under it.
14
+ * Workspace model v2 (docs/design/2026-09-23-workspace-module-contracts.md §6):
15
+ * nothing is installed. `oats-local.yaml` names the workspace, `oats sync`
16
+ * observes it over Git remotes and writes `oats-lock.json` (lockfileVersion 3).
17
+ * `init` / `use` / `install` / `restore` / `list` / `catalog` / `remove` /
18
+ * `migrate` / `trust` / `inject` are gone with the installed-capability tier.
15
19
  */
16
- import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
20
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
17
21
  import { execFileSync, spawnSync } from "node:child_process";
18
- import { homedir, tmpdir } from "node:os";
22
+ import { homedir } from "node:os";
19
23
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
20
24
  import { createHash } from "node:crypto";
21
25
  import { fileURLToPath } from "node:url";
22
- import { enableTmuxMouse, tmuxConfigPath, tmuxMouseEnabled } from "../lib/tmux-config.mjs";
23
26
  import {
24
- LAYERS, WORK_MODES, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
25
- acquireCapability, restoreCapabilities, marketplaceCapabilities,
26
- capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath, activateCapturedScaffold, loadCapturedDispatch, inspectPortableOnboarding, prepareCapturedComposition, resolveCapturedHelper, capturedNativeSessionAvailability, scaffoldCapturedInstance, startCapturedInstanceSession, withCapturedBindingFile, withCapturedInvocationContextFile,
27
- readCapabilityLocks, writeCapabilityLock, admitCapturedAction, beginCapturedIntent, settleCapturedIntent,
28
- parsePackageSource, inspectGitSourceRoot, acquirePackage, restorePackages, listInstalledPackages, readPackageLocks, readLockedConfigTemplates,
29
- officialCapabilityPackage, officialPackageCatalog, describeOfficialCatalog, DEFAULT_PACKAGE_PATH,
30
- approveCapability, approveAvailableCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
31
- packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
32
- resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, planInstanceResources, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
27
+ LAYERS, WORK_MODES, LEGACY_HOME_CAPABILITIES_DIR, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
28
+ capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityTrust, capabilityExecutablePath, activateCapturedScaffold, loadCapturedDispatch, inspectPortableOnboarding, prepareCapturedComposition, resolveCapturedHelper, capturedNativeSessionAvailability, scaffoldCapturedInstance, startCapturedInstanceSession, withCapturedBindingFile, withCapturedInvocationContextFile,
29
+ readCapabilityLocks, admitCapturedAction, beginCapturedIntent, settleCapturedIntent, listInstalledPackages, readPackageLocks,
30
+ officialPackageCatalog, describeOfficialCatalog, approveAvailableCapability,
31
+ packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir,
32
+ resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, stripInternalAnnotations, withConfigFile, teamAgentRoots,
33
33
  findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
34
34
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
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,
35
+ spawnInstance, spawnInstanceAsync, findModuleCapabilityAgent, capabilityAgentFromDir, 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,
36
36
  } from "../lib/core.mjs";
37
37
  import {
38
- aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
39
- adoptedTemplateDir, applyConfigMerge, lockedPackageCapabilities, planConfigMerge, readAdoptedTemplate, requirementInstallPlan,
40
- assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
41
- runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
38
+ assertNoSymlinkedParents, writeFileAtomic,
39
+ LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage, executablesDigest, readPackageTree,
40
+ classifyPackageValue, manifestExecutables, parsePackageRequest,
42
41
  } from "../lib/packages.mjs";
42
+ import { loadLocal, discoverWorkspace, validateWorkspace } from "../lib/workspace.mjs";
43
+ import * as remoteModule from "../lib/remote.mjs";
44
+ import { createInterface } from "node:readline/promises";
45
+ import YAML from "yaml";
43
46
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
44
47
  import { spawnSync as spawnSyncProc } from "node:child_process";
45
48
  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";
@@ -49,26 +52,22 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
49
52
  import { capturedSelector } from "../lib/captured-selector.mjs";
50
53
  import { inspectCapturedPiOutcome } from "../lib/captured-pi-host.mjs";
51
54
  import { readCapturedResolution } from "../lib/captured-resolutions.mjs";
52
- import { readLock3 } from "../lib/portable-lock.mjs";
53
55
  import { oatsError } from "../lib/errors.mjs";
54
- import { readPortableBytes } from "../lib/portable-files.mjs";
55
- import { canonicalJson, parseStrictJson } from "../lib/portable-values.mjs";
56
+ import { canonicalJson } from "../lib/portable-values.mjs";
56
57
  import { readPortablePreparationRequest } from "../lib/portable-onboarding-request.mjs";
57
58
  import { portableScope } from "../lib/portable-state.mjs";
58
59
  import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../lib/captured-operation-process.mjs";
59
60
  import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
60
- import { loadSetupExpertEdition, SETUP_EXPERT, SETUP_CAPABILITIES } from "../lib/setup-expert-source.mjs";
61
61
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
62
62
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
63
63
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
64
64
  import { readinessOf, policyOf } from "../lib/readiness.mjs";
65
65
  import { readEvents } from "../lib/instance-events.mjs";
66
- import { parsePortableSource } from "../lib/source-spec.mjs";
67
66
 
68
67
  const args = process.argv.slice(2);
69
68
  let cmd = args[0];
70
69
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
71
- const KERNEL_COMMANDS = new Set(["prepare", "capture", "config", "create", "doctor", "inspect", "instance", "operation", "readiness", "soul", "launch-config", "experimental", "onboard", "init", "inject", "install", "list", "catalog", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
70
+ const KERNEL_COMMANDS = new Set(["prepare", "capture", "capabilities", "create", "doctor", "inspect", "instance", "operation", "package", "readiness", "soul", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "type", "update", "version", "workspace"]);
72
71
  const flag = (name) => {
73
72
  const i = args.indexOf(`--${name}`);
74
73
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -83,6 +82,7 @@ function valueFlag(name) {
83
82
  return value;
84
83
  }
85
84
  const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
85
+ const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
86
86
  /** Resolve the --dir flag with central validation: a value-taking flag given
87
87
  * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
88
88
  * uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
@@ -506,26 +506,6 @@ function scaffoldConfigName(dir) {
506
506
  return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
507
507
  }
508
508
 
509
- function offerTmuxMouseScrolling() {
510
- if (args.includes("--no-tmux-mouse")) return;
511
- const configPath = tmuxConfigPath();
512
- const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
513
- if (tmuxMouseEnabled(current)) return;
514
-
515
- let accepted = args.includes("--tmux-mouse");
516
- if (!accepted) {
517
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
518
- process.stdout.write("Enable normal mouse/trackpad scrolling in tmux agent windows? [Y/n] ");
519
- const buffer = Buffer.alloc(256);
520
- const length = readSync(process.stdin.fd, buffer, 0, buffer.length);
521
- accepted = !buffer.subarray(0, length).toString("utf8").trim().toLowerCase().startsWith("n");
522
- }
523
- if (!accepted) return;
524
-
525
- const result = enableTmuxMouse(configPath);
526
- console.log(`Enabled tmux mouse scrolling in ${shortPath(result.configPath)}${result.reloaded ? " (reloaded)" : ""}`);
527
- }
528
-
529
509
  // ---------- doctor ----------
530
510
  /** Doctor must diagnose, not crash: a stale activation of a retired
531
511
  * capability fails config resolution — surface the cleanup instruction
@@ -560,31 +540,32 @@ function doctorComposition(ctx, soulName) {
560
540
  if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
561
541
  return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
562
542
  }
563
- /** WS2 package-layer doctor data — the ONE source for both human and --json
564
- * doctor output: lock v2 packages, adopted-profile provenance, available-but-
565
- * unapplied profiles, and missing host requirements with structured plans. */
566
- /** Guided-upgrade readiness for the legacy official capabilities visible from a
567
- * scope (release contract §4): which legacy `marketplace:` locks exist, which
568
- * official package supplies each one, and whether this release's catalog can
569
- * map them all yet. `null` when there is no legacy official state at all. */
570
- function officialMigrationState(legacyLocks, { teamScope, ctx }) {
571
- const capabilities = [];
572
- for (const l of legacyLocks) {
573
- for (const [id, entry] of Object.entries(l.capabilities || {})) {
574
- if (typeof entry?.source !== "string" || !entry.source.startsWith("marketplace:")) continue;
575
- let m;
576
- try { m = officialCapabilityPackage(id); }
577
- catch (e) { return { status: "unavailable", capabilities: [], command: null, reason: `the official package catalog is unreadable: ${e.message}` }; }
578
- capabilities.push({ capability: id, package: m.package, via: m.via, available: m.available, file: l.file, level: l.level });
579
- }
543
+
544
+ /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
545
+ * walking up from ctx (oats-local.yaml) and the lock v3 beside it. Doctor never
546
+ * goes to the network; membership and discovery are `oats sync` / `oats workspace status`. */
547
+ function doctorLockData(ctx) {
548
+ const out = { local: null, localError: null, lockFile: null, packages: [], lockError: null };
549
+ let lockDir = ctx;
550
+ try {
551
+ const found = loadLocal(ctx);
552
+ out.local = { path: found.path, workspace: found.local.workspace };
553
+ lockDir = dirname(found.path);
554
+ } catch (e) {
555
+ if (e?.code === "E_WORKSPACE_SCHEMA") out.localError = { code: e.code, message: e.message };
556
+ else if (e?.code !== "E_LOCAL_MISSING") throw e;
557
+ }
558
+ const file = join(lockDir, LOCK_FILE);
559
+ if (!existsSync(file)) return out;
560
+ out.lockFile = file;
561
+ try {
562
+ const lock = readLock(lockDir);
563
+ out.packages = Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, path: p.path, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
564
+ } catch (e) {
565
+ if (e?.code !== "E_LOCK_SCHEMA") throw e;
566
+ out.lockError = { code: e.code, message: e.message, file: e.details?.file ?? file };
580
567
  }
581
- if (!capabilities.length) return null;
582
- const boundary = teamScope || ctx;
583
- const command = `oats migrate --official --recursive --dir ${shellQuote(boundary)}`;
584
- const missing = capabilities.filter((c) => !c.available);
585
- return missing.length
586
- ? { status: "unavailable", capabilities, command: null, reason: `no official package mapping yet for ${missing.map((c) => c.capability).join(", ")} — this release keeps the legacy capabilities working; migration becomes available when the catalog publishes them` }
587
- : { status: "ready", capabilities, command, reason: null };
568
+ return out;
588
569
  }
589
570
 
590
571
  /** The health of ONE materialized capability, against the rows it was projected
@@ -608,7 +589,7 @@ function hasExecutableSurface(manifest) {
608
589
  }
609
590
  function capabilityHealth(level, cap, capRow, pkgRow) {
610
591
  const dir = installedCapabilityDir(level, cap.id);
611
- if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats install\` to re-materialize it` };
592
+ if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats sync\` to re-materialize it` };
612
593
  let integrity;
613
594
  try { integrity = capabilityArtifactIntegrity(dir); }
614
595
  catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
@@ -622,113 +603,10 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
622
603
  catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
623
604
  }
624
605
  const executable = hasExecutableSurface(cap.manifest);
625
- if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
606
+ if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — approve it in \`oats sync\`` };
626
607
  return { status: "ok", code: null, dir, integrity, detail: null };
627
608
  }
628
609
 
629
- function doctorPackagesData(ctx, chain, { teamScope } = {}) {
630
- // reviewer-455ba15 fix 4: the ENGINE diagnostics the human doctor renders
631
- // (invalid locks, missing artifacts, integrity/runtime-closure drift,
632
- // capability-list mismatches, untrusted surfaces, legacy-lock states)
633
- // are computed HERE so doctor --json exposes them structurally — machine
634
- // consumers see every state the human report calls broken. Fail-closed
635
- // reads are diagnosed, never consumed as data and never swallowed.
636
- let pkgLocks = { packages: {}, legacy: [] };
637
- let installedPkgs = [];
638
- let lockBroken = null;
639
- try { pkgLocks = readPackageLocks(ctx); installedPkgs = listInstalledPackages(ctx); }
640
- catch (e) {
641
- const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
642
- lockBroken = { code: e.code || "invalid-lock", message: String(e.message || e), file: prov?.file || null, provenance: e.provenance || null };
643
- }
644
- const packages = [];
645
- for (const p of installedPkgs) {
646
- // SCOPE-EXACT, like `oats list` and `oats trust` above: `p` was derived from
647
- // the lock AT `p.level`, and its artifacts live under that scope, so only
648
- // that scope's rows can judge them. The MERGED maps resolve each identity
649
- // closest-scope-first — right for "which capability is active here", wrong
650
- // here — so a chain holding one package id at two scopes (a direct
651
- // acquisition outside, the same id pulled in by a dependency closure
652
- // inside, each with its own source spelling) would compare an outer
653
- // artifact's provenance against the inner row it was never projected from
654
- // and report a self-consistent pair as invalid-lock.
655
- const rows = levelRows(pkgLocks, p.level);
656
- const lock = Object.hasOwn(rows.packages, p.package) ? { ...rows.packages[p.package], _file: join(p.level, OATS_LOCK_FILE), _level: p.level } : undefined;
657
- const problems = [];
658
- if (!lock) problems.push({ code: "invalid-lock", detail: "installed but not locked — reacquire it" });
659
- else {
660
- // There is no persistent package root to hash: the package row exact-locks
661
- // a remote payload, and the only bytes on disk are the flat capability
662
- // artifacts. So every health check is per capability, against the artifact
663
- // integrity the engine recorded for it.
664
- for (const c of p.capabilities) {
665
- const h = capabilityHealth(p.level, c, Object.hasOwn(rows.capabilities, c.id) ? rows.capabilities[c.id] : undefined, lock);
666
- if (h.status !== "ok") problems.push({ code: h.code, detail: h.detail });
667
- }
668
- }
669
- packages.push({
670
- id: p.package, version: p.version || null, level: p.level, source: lock?.source || null,
671
- path: lock?.path || null, commit: lock?.commit || null, capabilities: p.capabilities.map((c) => c.id),
672
- dependencies: lock?.dependencies || [],
673
- status: problems.length ? "broken" : "ok", problems,
674
- });
675
- }
676
- for (const [id, lock] of Object.entries(pkgLocks.packages)) {
677
- if (!installedPkgs.some((p) => p.package === id)) {
678
- // Capability rows carry the provider back-reference — the package row has
679
- // no capability list to read any more.
680
- const provided = Object.entries(pkgLocks.capabilities).filter(([, c]) => c.package === id).map(([capId]) => capId);
681
- packages.push({ id, version: lock.version || null, level: lock._level, source: lock.source || null, path: lock.path || null, commit: lock.commit || null, capabilities: provided, dependencies: lock.dependencies || [], status: "broken", problems: [{ code: "missing-locked-package", detail: `locked in ${lock._file} but not installed — run oats install` }] });
682
- }
683
- }
684
- // Supported v1 scopes — empty or not — are pending an explicit LOCK-FORMAT
685
- // migration (maintainer ruling). There is no second view beside this one:
686
- // migration never produces residue, and the superseded transitional v2 shape
687
- // is rejected wholesale by the strict reader, so it reaches doctor as the
688
- // single `lockError` diagnosis above rather than as partially parsed entries.
689
- const legacyLockFiles = pkgLocks.legacy
690
- .map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion ?? 1, empty: !Object.keys(l.capabilities || {}).length, status: "pending-format-migration", action: `oats migrate --dir ${l.level}` }));
691
- // Adoption provenance now comes from the visible, commit-safe adopted base —
692
- // not from a provenance comment the local config could lose to an edit.
693
- const adoptedTemplates = [];
694
- for (const cfg of chain) {
695
- const level = dirname(cfg._file);
696
- let adopted;
697
- try { adopted = readAdoptedTemplate(level); }
698
- catch (e) { adoptedTemplates.push({ level, file: cfg._file, status: "broken", code: e.code || "E_ADOPTION_INVALID", detail: e.message }); continue; }
699
- if (!adopted) continue;
700
- let localChanges = null;
701
- try { localChanges = readFileSync(cfg._file, "utf8") !== adopted.baseText; } catch { /* unreadable config is reported elsewhere */ }
702
- adoptedTemplates.push({
703
- level, file: cfg._file, package: adopted.package, template: adopted.template,
704
- base: adopted.baseFile, source: adopted.metadata?.source || null,
705
- version: adopted.metadata?.version || null, commit: adopted.metadata?.commit || null,
706
- hash: adopted.metadata?.hash || null, localChanges, status: "ok",
707
- });
708
- }
709
- // NOTE: doctor deliberately does NOT enumerate templates a package exports but
710
- // nobody adopted. In the materialized model there is no package root on disk,
711
- // so that list only exists behind a network fetch of the locked source — and
712
- // a diagnostic command must never go to the network to render a hint.
713
- const missingHostRequirements = aggregateMissingRequirements([ctx]).map((req) => ({
714
- command: req.command, why: req.why || null, docs: req.docs || null,
715
- requestedBy: req.requestedBy,
716
- plan: req.plan && !req.plan.unavailable
717
- ? { manager: req.plan.manager, argv: req.plan.argv, steps: req.plan.steps || [req.plan.argv], source: req.plan.source, version: req.plan.version || null, scope: req.plan.scope }
718
- : null,
719
- invalid: req.invalid || null,
720
- conflict: req.conflict || null,
721
- unavailable: req.plan?.unavailable || null,
722
- // Context-complete + shell-safe: the copyable command pins the resolved
723
- // scope with --dir so it cannot target another deployment from a
724
- // different cwd. Command and ctx are validated/quoted for safe copying.
725
- consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
726
- ? `oats install --accept-requirement ${req.command} --dir ${shellQuote(ctx)}`
727
- : null,
728
- }));
729
- return { lockError: lockBroken, packages, legacyLockFiles, adoptedTemplates, missingHostRequirements, officialMigration: officialMigrationState(pkgLocks.legacy, { teamScope, ctx }) };
730
- }
731
-
732
610
  // ---------- inspect: one authoritative answer for GUIs ----------
733
611
  /** Souls, capabilities (installed state and health, separately from
734
612
  * activation), effective layer bindings and declared operations for a
@@ -937,7 +815,7 @@ function computeInspect({ onFail } = {}) {
937
815
  // Capabilities: installed state and health from the package engine (exactly
938
816
  // what `oats list` reports), owned/path manifests beside them, and the
939
817
  // ACTIVATION for the selected soul (or global) from the resolver.
940
- const mans = capabilityManifests(ctx);
818
+ const mans = capabilityManifests(manifestSource(meta, home, ctx));
941
819
  let lockError = null;
942
820
  const byId = new Map();
943
821
  try {
@@ -998,7 +876,7 @@ function computeInspect({ onFail } = {}) {
998
876
  const operations = manifestOperations(m).map((op) => {
999
877
  let reason = null;
1000
878
  if (!active) reason = disabledLayer ? `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` : `${entry.id} is not activated for ${meta ? `home ${basename(home)}` : soulName ? `soul ${soulName}` : "this scope"}`;
1001
- else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (oats trust ${entry.id})`;
879
+ else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (approve it in oats sync)`;
1002
880
  else if (entry.health.status !== "ok") reason = entry.health.detail || entry.health.status;
1003
881
  else if (missingRequires.length) reason = `${entry.id} requires ${missingRequires.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
1004
882
  else if (op.context === "home" && !home) reason = "needs a running home (--home)";
@@ -1201,7 +1079,7 @@ function operationCmd() {
1201
1079
  }
1202
1080
  // Provider resolution: the snapshot's active capabilities for a home, the
1203
1081
  // config for a soul/scope.
1204
- const mans = capabilityManifests(ctx);
1082
+ const mans = capabilityManifests(manifestSource(meta, home, ctx));
1205
1083
  let provider, settings, team, disabled = null;
1206
1084
  if (meta) {
1207
1085
  const ids = (meta.capabilities || []).map((c) => c.id);
@@ -1222,7 +1100,7 @@ function operationCmd() {
1222
1100
  const op = manifestOperations(provider).find((o) => o.name === opName);
1223
1101
  if (!op) bail("E_OPERATION_UNKNOWN", `${provider.capability} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
1224
1102
  const trust = capabilityTrust(provider, ctx);
1225
- if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (oats trust ${provider.capability})`);
1103
+ if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (approve it in oats sync)`);
1226
1104
  const missingReq = capabilityMissingRequires(provider.capability, ctx);
1227
1105
  if (missingReq.length) bail("E_CAPABILITY_REQUIRES", `${provider.capability} requires ${missingReq.map((m) => `"${m.command}" on PATH${m.why ? ` (${m.why})` : ""}${m.install ? ` [install: ${m.install}]` : ""}`).join(", ")}; ${address} was not run`);
1228
1106
  if (op.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
@@ -1347,11 +1225,14 @@ async function soulCmd() {
1347
1225
  function doctorJson(dir) {
1348
1226
  const ctx = resolve(dir || process.cwd());
1349
1227
  const soulName = flag("soul");
1228
+ const ws = doctorLockData(ctx);
1229
+ // A v2 deployment (oats-local.yaml found walking up) has NO config chain, layers,
1230
+ // acquired packages or installed tier: those keys are omitted, not emitted empty.
1231
+ if (ws.local) { console.log(JSON.stringify(doctorWorkspaceJson(ctx, soulName, ws), null, 2)); return; }
1350
1232
  const r = resolveForDoctor(ctx, soulName, { json: true });
1351
1233
  const mans = capabilityManifests(ctx);
1352
1234
  const composition = doctorComposition(ctx, soulName);
1353
1235
  const chain = configChain(ctx);
1354
- const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
1355
1236
  const oasScopes = detectOasScopes(ctx);
1356
1237
  console.log(JSON.stringify({
1357
1238
  schemaVersion: 1,
@@ -1377,34 +1258,77 @@ retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); }
1377
1258
  retiredArtifacts: Object.entries(mans)
1378
1259
  .filter(([id]) => retiredCapabilityReason(id))
1379
1260
  .map(([id, m]) => ({ id, dir: m._dir, origin: m._origin, reason: retiredCapabilityReason(id) })),
1380
- // Shared WS2+engine package payload (fix 4: human and JSON doctor derive
1381
- // from ONE computation; fail-closed reads are diagnosed via lockError —
1382
- // doctorPackagesData carries the engine's legacy-lock shapes).
1383
- packages: pkg.packages,
1384
- lockError: pkg.lockError,
1385
- legacyLockFiles: pkg.legacyLockFiles,
1386
- officialMigration: pkg.officialMigration,
1387
- adoptedTemplates: pkg.adoptedTemplates,
1388
- missingHostRequirements: pkg.missingHostRequirements,
1261
+ // Workspace model v2 (offline view): the lock (v3) and the deployment
1262
+ // declaration. Human and JSON doctor derive from ONE computation; a
1263
+ // fail-closed lock read is diagnosed via lockError, never consumed as data.
1264
+ workspace: ws.local ? { file: ws.local.path, ref: ws.local.workspace } : null,
1265
+ workspaceError: ws.localError,
1266
+ lockFile: ws.lockFile,
1267
+ packages: ws.packages,
1268
+ lockError: ws.lockError,
1389
1269
  composedInstructions: composition?.text,
1390
1270
  instructionBlocks: composition?.blocks,
1391
1271
  }, null, 2));
1392
1272
  }
1393
1273
 
1274
+ /** The v2 doctor payload: the deployment declaration + lock (offline) and, with
1275
+ * --soul, the composed instructions. No v1 keys (chain/layers/acquired/injects…). */
1276
+ function doctorWorkspaceJson(ctx, soulName, ws) {
1277
+ const composition = doctorComposition(ctx, soulName);
1278
+ return {
1279
+ schemaVersion: 1, workspaceApi: 2, context: ctx,
1280
+ workspace: { file: ws.local.path, ref: ws.local.workspace },
1281
+ workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
1282
+ information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1283
+ composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
1284
+ };
1285
+ }
1286
+ /** Kernel/bridge version skew (published in lockstep from one tag). */
1287
+ function doctorVersionSkew() {
1288
+ const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
1289
+ if (!existsSync(piPkgFile)) return;
1290
+ const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
1291
+ if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
1292
+ }
1293
+ /** The "Workspace (v2, offline view)" + "Locked packages" sections, shared by both doctor shapes. */
1294
+ function printDoctorWorkspace(ws) {
1295
+ console.log("\nWorkspace (v2, offline view):");
1296
+ if (ws.local) console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
1297
+ else if (ws.localError) console.log(` ERROR: ${ws.localError.message} [${ws.localError.code}]`);
1298
+ else console.log(" (no oats-local.yaml found walking up — this scope realizes no v2 workspace; run `oats sync` from one that does)");
1299
+ console.log("\nLocked packages (oats-lock.json v3):");
1300
+ if (ws.lockError) {
1301
+ console.log(` ERROR: ${ws.lockError.message} [${ws.lockError.code}]`);
1302
+ if (ws.lockError.file) console.log(` the lock is never auto-repaired; delete ${shortPath(ws.lockError.file)} and run \`oats sync\``);
1303
+ } else if (!ws.packages.length) console.log(ws.lockFile ? " (none)" : " (no lock yet — run `oats sync`)");
1304
+ for (const p of ws.packages) {
1305
+ console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.approved ? `approved ${p.approved.at}` : "APPROVAL NEEDED (oats sync)"}`);
1306
+ if (p.capabilities.length) console.log(` capabilities: ${p.capabilities.join(", ")}`);
1307
+ }
1308
+ console.log(" membership, discovery and drift need the remotes: `oats workspace status`, `oats sync`.");
1309
+ }
1394
1310
  function doctor(dir) {
1395
1311
  const ctx = resolve(dir || process.cwd());
1396
1312
  const soulName = flag("soul");
1313
+ const ws = doctorLockData(ctx);
1314
+ console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1315
+ doctorVersionSkew();
1316
+ if (ws.local) {
1317
+ // A v2 deployment: nothing is installed and there is no config chain — the v1
1318
+ // sections (Config chain / Layers / Kernel injection / Acquired packages / lock
1319
+ // warnings) would describe a tier this deployment does not have.
1320
+ const composition = doctorComposition(ctx, soulName);
1321
+ printDoctorWorkspace(ws);
1322
+ if (soulName) {
1323
+ const information = operationalKnowledgeNote(composition, soulName);
1324
+ if (information) console.log(`\nINFO: ${information}`);
1325
+ console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
1326
+ } else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
1327
+ return;
1328
+ }
1397
1329
  const chain = configChain(ctx);
1398
1330
  const r = resolveForDoctor(ctx, soulName);
1399
1331
  const composition = doctorComposition(ctx, soulName);
1400
- console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1401
-
1402
- // Kernel/bridge version skew (published in lockstep from one tag).
1403
- const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
1404
- if (existsSync(piPkgFile)) {
1405
- const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
1406
- if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
1407
- }
1408
1332
 
1409
1333
  console.log("Config chain (closest first):");
1410
1334
  if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
@@ -1485,7 +1409,7 @@ function doctor(dir) {
1485
1409
  for (const [id, lock] of Object.entries(locks)) {
1486
1410
  const retiredReason = retiredCapabilityReason(id);
1487
1411
  if (retiredReason) { console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but ${retiredReason}`); continue; }
1488
- if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats install\``);
1412
+ if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats sync\``);
1489
1413
  }
1490
1414
  for (const [id, m] of Object.entries(mans)) {
1491
1415
  if (!String(m._origin).startsWith("installed:")) continue;
@@ -1500,54 +1424,10 @@ function doctor(dir) {
1500
1424
  }
1501
1425
  if (existsSync(LEGACY_HOME_CAPABILITIES_DIR)) console.log(` WARNING: legacy ~/.oats/capabilities exists and is no longer discovered — reinstall its packages at a config scope and remove it`);
1502
1426
 
1503
- // Distribution packages: package failures are distinguished from capability
1504
- // failures. Doctor is the DIAGNOSIS surface — human and JSON render the SAME
1505
- // doctorPackagesData computation (reviewer-455ba15 fix 4); fail-closed
1506
- // invalid-lock raises are diagnosed here, never consumed as data.
1507
- console.log("\nInstalled packages:");
1508
- const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
1509
- if (pkg.lockError) {
1510
- console.log(` ERROR: ${pkg.lockError.message} [${pkg.lockError.code}]`);
1511
- if (pkg.lockError.file) console.log(` fix or remove the offending entry in ${shortPath(pkg.lockError.file)} — the lock is never auto-repaired; package operations fail closed until it is valid`);
1512
- }
1513
- if (!pkg.lockError && !pkg.packages.length && !pkg.legacyLockFiles.length) console.log(" (none)");
1514
- for (const p of pkg.packages) {
1515
- console.log(` ${p.id}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]`);
1516
- for (const prob of p.problems) {
1517
- if (prob.code === "untrusted-surface") console.log(` ${prob.detail}`);
1518
- else console.log(` ERROR: ${prob.detail} [${prob.code}]`);
1519
- }
1520
- }
1521
- for (const l of pkg.legacyLockFiles) {
1522
- if (l.empty) console.log(` WARNING: ${shortPath(l.file)} is an empty lockfileVersion ${l.lockfileVersion} file — pending lock-format migration: run \`oats migrate --dir ${shortPath(l.level)}\` (converts to canonical v2)`);
1523
- else console.log(` WARNING: ${shortPath(l.file)} is lockfileVersion ${l.lockfileVersion} — \`oats migrate\` maps its capability locks to packages`);
1524
- }
1525
- if (pkg.officialMigration) {
1526
- const om = pkg.officialMigration;
1527
- console.log(`\nOfficial capability migration (0.18 bundled capabilities → official packages):`);
1528
- for (const c of om.capabilities) {
1529
- console.log(` ${c.capability} → package ${c.package}${c.via === "alias" ? " (catalog alias)" : ""} ${c.available ? "[mapped]" : "[no catalog mapping yet]"} [${shortPath(c.level)}]`);
1530
- }
1531
- if (om.status === "ready") console.log(` READY: migrate with \`${om.command}\` (plan it first with --dry-run; approvals are re-earned afterwards)`);
1532
- else console.log(` NOT YET AVAILABLE: ${om.reason}`);
1533
- }
1534
- for (const a of pkg.adoptedTemplates) {
1535
- if (a.status === "broken") {
1536
- console.log(`\nAdopted config template: BROKEN at ${shortPath(a.level)} — ${a.detail}`);
1537
- continue;
1538
- }
1539
- const drift = a.localChanges === null ? "" : a.localChanges ? " — local edits present (`oats config diff`)" : " — no local edits yet";
1540
- console.log(`\nAdopted config template: ${shortPath(a.file)} adopted ${a.package}:${a.template}${a.version ? `@${a.version}` : ""}${drift}`);
1541
- console.log(` recorded base ${shortPath(a.base)} (commit it — \`oats config sync\` compares against it; package updates never rewrite your config)`);
1542
- }
1543
- if (pkg.missingHostRequirements.length) {
1544
- console.log("\nMissing host commands (active capabilities):");
1545
- for (const req of pkg.missingHostRequirements) {
1546
- console.log(` ${req.command} — ${req.why || "required"} (requested by: ${req.requestedBy.map((r) => r.capability).join(", ")})`);
1547
- if (req.plan) console.log(` install with consent: ${req.consentCommand} (runs: ${req.plan.argv.join(" ")})`);
1548
- else if (req.docs) console.log(` install docs: ${req.docs}`);
1549
- }
1550
- }
1427
+ // Workspace model v2: nothing is installed. Doctor reports the lock (v3) and
1428
+ // the deployment declaration OFFLINE — membership, discovery and package
1429
+ // resolution go to the remotes and belong to `oats sync` / `oats workspace status`.
1430
+ printDoctorWorkspace(ws);
1551
1431
 
1552
1432
  if (soulName) {
1553
1433
  const information = operationalKnowledgeNote(composition, soulName);
@@ -1557,86 +1437,6 @@ function doctor(dir) {
1557
1437
  }
1558
1438
 
1559
1439
  // ---------- config editing (structural: parse → mutate → re-serialize the capabilities block) ----------
1560
- function originToFrom(origin) {
1561
- const o = String(origin || "");
1562
- if (o.startsWith("installed:")) return "installed";
1563
- if (o.startsWith("owned:")) return "owned";
1564
- if (o.startsWith("path:")) return undefined; // path declarations stay hand-authored
1565
- return undefined;
1566
- }
1567
-
1568
- function serializeBinding(value, indent) {
1569
- if (value === true || value === false) return ` ${value}`;
1570
- const lines = [""];
1571
- if (value.enabled !== undefined) lines.push(`${indent}enabled: ${value.enabled}`);
1572
- if (value.settings && Object.keys(value.settings).length) {
1573
- lines.push(`${indent}settings:`);
1574
- for (const [k, v] of Object.entries(value.settings)) lines.push(`${indent} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
1575
- }
1576
- return lines.join("\n");
1577
- }
1578
-
1579
- /** Serialize one capability entry map at the given base indent, with the conventional injection comment. */
1580
- function serializeCapabilityEntry(id, entry, baseIndent) {
1581
- const i = baseIndent;
1582
- const lines = [];
1583
- if (entry.capability) lines.push(`${i}capability: ${entry.capability}`);
1584
- if (entry.from) lines.push(`${i}from: ${entry.from}`);
1585
- if (entry.global !== undefined) lines.push(`${i}global:${serializeBinding(entry.global, i + " ")}`);
1586
- const types = entry["agent-types"];
1587
- if (types && Object.keys(types).length) {
1588
- lines.push(`${i}agent-types:`);
1589
- for (const [t, v] of Object.entries(types)) lines.push(`${i} ${t}:${serializeBinding(v, i + " ")}`);
1590
- }
1591
- if (entry.souls && Object.keys(entry.souls).length) {
1592
- lines.push(`${i}souls:`);
1593
- for (const [s, v] of Object.entries(entry.souls)) lines.push(`${i} ${s}:${serializeBinding(v, i + " ")}`);
1594
- }
1595
- if (entry.settings && Object.keys(entry.settings).length) {
1596
- lines.push(`${i}settings:`);
1597
- for (const [k, v] of Object.entries(entry.settings)) lines.push(`${i} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
1598
- }
1599
- if (entry["injection-override"] !== undefined) lines.push(`${i}injection-override: ${entry["injection-override"]}`);
1600
- else if (entry.from === "owned" || String(entry.from || "").startsWith("path:"))
1601
- lines.push(`${i}# injection edited at source: .agents/capabilities/owned/${id}/injects/`);
1602
- else lines.push(`${i}# injection-override: .agents/injections/capabilities/${id}.md`);
1603
- return lines;
1604
- }
1605
-
1606
- /** Re-serialize the whole `capabilities:` block from its parsed model. */
1607
- function serializeCapabilities(caps) {
1608
- const lines = ["capabilities:", " # Fundamental layers — exclusive slots; a capability entry or an explicit none.", " layers:"];
1609
- for (const layer of LAYERS) {
1610
- const entry = caps.layers?.[layer];
1611
- if (entry === undefined) continue;
1612
- if (entry === "none") { lines.push(` ${layer}: none`); continue; }
1613
- lines.push(` ${layer}:`);
1614
- lines.push(...serializeCapabilityEntry(entry.capability, entry, " "));
1615
- }
1616
- const additive = Object.entries(caps.additive || {});
1617
- if (additive.length) {
1618
- lines.push(" additive:");
1619
- for (const [id, entry] of additive) {
1620
- lines.push(` ${id}:`);
1621
- lines.push(...serializeCapabilityEntry(id, entry, " "));
1622
- }
1623
- }
1624
- return lines.join("\n") + "\n";
1625
- }
1626
-
1627
- /** Replace (or append) the top-level capabilities: block in config text. */
1628
- function replaceCapabilitiesBlock(text, caps) {
1629
- const serialized = serializeCapabilities(caps);
1630
- const lines = text.replace(/\n*$/, "\n").split("\n");
1631
- const start = lines.findIndex((l) => /^capabilities:\s*(#.*)?$/.test(l));
1632
- if (start < 0) return text.replace(/\n*$/, "\n\n") + serialized;
1633
- let end = lines.length;
1634
- for (let i = start + 1; i < lines.length; i++) {
1635
- if (/^[^\s#]/.test(lines[i])) { end = i; break; }
1636
- if (/^#/.test(lines[i]) && i + 1 < lines.length && /^[^\s]/.test(lines[i + 1] || "")) { end = i; break; }
1637
- }
1638
- return [...lines.slice(0, start), ...serialized.replace(/\n$/, "").split("\n"), "", ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
1639
- }
1640
1440
  /** Replace (or append, or drop with "") the top-level launch-configs block:
1641
1441
  * the span from its key line (bare, quoted, or the inline `launch-configs: {...}`
1642
1442
  * form) to the next top-level line is replaced; every byte before and after
@@ -1887,2248 +1687,568 @@ async function launchConfigCmd() {
1887
1687
  }
1888
1688
 
1889
1689
 
1890
- /** Load the parsed capabilities model of a config file ({layers:{}, additive:{}}). */
1891
- function readCapabilitiesModel(file) {
1892
- if (!existsSync(file)) return { layers: {}, additive: {} };
1893
- const cfg = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8")));
1894
- const caps = cfg.capabilities || {};
1895
- return { layers: { ...(caps.layers || {}) }, additive: { ...(caps.additive || {}) } };
1896
- }
1897
-
1898
- // ---------- use / activation ----------
1899
- function use() {
1900
- const requested = args[1];
1901
- if (!requested || requested.startsWith("--")) cmdFail("E_USAGE", "usage: oats use <capability|none> [--global|--type <agent-type>|--soul <name>] [--disable|--inherit] [--layer <name>] [--settings k=v [k2=v2 ...]] [--dir <dir>] [--json]");
1902
- const dir = dirFlag();
1903
- const level = levelOf(dir);
1904
- const file = join(dir, "oats-config.yaml");
1905
- const layer = flag("layer");
1906
- if (layer && !LAYERS.includes(layer)) cmdFail("E_BAD_ARGS", `--layer must be one of: ${LAYERS.join(", ")}`);
1907
- const inherit = args.includes("--inherit");
1908
- if (inherit && args.includes("--disable")) cmdFail("E_BAD_ARGS", "choose --inherit (remove this level's binding) or --disable (explicit exclusion), not both");
1909
- let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
1910
- const caps = readCapabilitiesModel(file);
1911
- // The receipt names what this level said before and after, and what is
1912
- // effective afterwards, so a GUI never has to re-read the file to know.
1913
- const effectiveAfter = (soulName, layerName, capId) => {
1690
+ /** `oats instance <git|diff> <instance>` — K1: read-only Git observation of one
1691
+ * instance's work tree. The instance is addressed qualified: an explicit
1692
+ * --home, or a name under the --dir scope (team roots included) that resolves
1693
+ * to exactly one home; several homes refuse with every candidate named. */
1694
+ function instanceCmd() {
1695
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1696
+ const sub = args[1], name = args[2];
1697
+ const usage = "usage: oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
1698
+ if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
1699
+ dropAmbientRoot();
1700
+ if (sub === "events") {
1701
+ // K7: typed producer events, bounded window; nothing inferred.
1702
+ const homeOpt = flag("home"); if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1703
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1704
+ const limit = flag("limit"); const since = flag("since");
1705
+ if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
1914
1706
  try {
1915
- const r = resolveOatsConfig(dir, soulName);
1916
- if (layerName) return { layer: layerName, id: r.layers[layerName]?.id || null, provenance: r.provenance[layerName] || null, disabled: !!r.layerDisabled?.[layerName] };
1917
- const c = r.capabilities.find((x) => x.id === capId);
1918
- return { capability: capId, enabled: !!c, provenance: c?.provenance || [], settings: c?.settings || {} };
1919
- } catch (e) { return { error: e.message }; }
1920
- };
1921
- const answer = (receipt, line) => { if (JSON_MODE) jsonOk(receipt); else console.log(line); };
1922
- if (requested === "none") {
1923
- if (!layer) cmdFail("E_BAD_ARGS", "oats use none requires --layer <name>");
1924
- // A layer `none` is a LEVEL statement; there is no per-soul or per-type
1925
- // none, so a target here must be refused, never silently widened.
1926
- if (flag("soul") !== undefined || flag("type") !== undefined) cmdFail("E_BAD_ARGS", `oats use none --layer ${layer} disables the layer for this whole level; it takes no --soul or --type (exclude one capability for a soul with oats use <capability> --soul <name> --disable)`);
1927
- const before = caps.layers[layer] === "none" ? "none" : caps.layers[layer] ? caps.layers[layer].capability : null;
1928
- if (inherit) {
1929
- if (caps.layers[layer] !== "none") cmdFail("E_NOT_BOUND", `layer ${layer} is not explicitly none at ${level} level (${shortPath(file)}); nothing to inherit from`);
1930
- delete caps.layers[layer];
1931
- writeFileSync(file, replaceCapabilitiesBlock(text, caps));
1932
- answer({ capability: null, action: "inherit", target: "layer", layer, level, file, before: { layer: before }, after: { layer: null, effective: effectiveAfter(undefined, layer) } }, `Layer ${layer} at ${level} level now inherits (${shortPath(file)})`);
1707
+ const { resolveInstance } = await_import_lifecycle();
1708
+ // K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
1709
+ // exactly this name under the scope (E_HOME_MISMATCH otherwise).
1710
+ const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
1711
+ const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
1712
+ if (JSON_MODE) { jsonOk(ev); return; }
1713
+ console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
1714
+ for (const e of ev.events) console.log(` ${e.at ?? "?"} ${e.kind.padEnd(20)} ${e.producer}${e.data ? ` ${JSON.stringify(e.data).slice(0, 120)}` : ""}`);
1933
1715
  return;
1934
- }
1935
- caps.layers[layer] = "none";
1936
- writeFileSync(file, replaceCapabilitiesBlock(text, caps));
1937
- answer({ capability: null, action: "layer-none", target: "layer", layer, level, file, before: { layer: before }, after: { layer: "none", effective: effectiveAfter(undefined, layer) } }, `Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
1938
- return;
1716
+ } catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
1939
1717
  }
1940
- const manifest = capabilityManifest(requested, dir);
1941
- if (!manifest) {
1942
- // A scope with NO oats-config.yaml anywhere in its chain is not a config
1943
- // level, so `capabilityManifests` — which walks the chain — never opens this
1944
- // scope's installed store: a capability acquired and locked right here would
1945
- // otherwise be reported as never acquired. Diagnose the missing chain
1946
- // instead. `oats use` still writes nothing: authoring an adopter's first
1947
- // config is `oats init`'s job, and guessing it here would be policy.
1948
- if (!configChain(dir).length && ownScopeCapabilityManifest(dir, requested)) {
1949
- // The remedy is `--raw` on purpose: it is offline, deterministic, and
1950
- // writes only the minimal config this scope is missing. It never names
1951
- // `--package <pkg>` — that arm read the provider out of the MERGED lock
1952
- // chain while this gate reads own-scope only, and it dead-ends whenever
1953
- // the provider exports no config template. Both scope mentions use the
1954
- // same rendering, so the printed command is copyable verbatim.
1955
- cmdFail("E_NO_CONFIG", `capability "${requested}" is present in the capability store at ${shellQuote(dir)}, but there is no oats-config.yaml at this scope or any level above it — \`oats use\` activates into a config file and this scope has none. Create the minimal one with \`oats init --raw --dir ${shellQuote(dir)}\`, then re-run \`oats use ${requested}\`.`);
1718
+ if (sub === "stop") {
1719
+ // K3: plan → apply. The plan is what a confirmation shows; apply carries
1720
+ // its revision back and refuses if reality moved.
1721
+ const homeOpt = flag("home");
1722
+ if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1723
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1724
+ const recursive = !args.includes("--no-recursive");
1725
+ const wantPlan = args.includes("--plan"), wantApply = args.includes("--apply");
1726
+ if (wantPlan === wantApply) return bail("E_BAD_ARGS", "stop needs exactly one of --plan or --apply");
1727
+ try {
1728
+ if (wantPlan) {
1729
+ const plan = planStop(dirFlag(), root, name, { home: homeOpt, recursive });
1730
+ if (JSON_MODE) { jsonOk(plan); return; }
1731
+ console.log(`stop ${name}${recursive ? " (and recorded children)" : ""} — plan ${plan.planRevision}`);
1732
+ for (const t of plan.targets) console.log(` ${" ".repeat(t.depth)}${t.instance}: session ${t.session.state}${t.work.observed ? `, ${t.work.changed} changed / ${t.work.untracked} untracked on ${t.work.branch ?? "detached"}` : ", work not observed"}${t.midTask === true ? " — mid-task" : t.midTask === "unknown" ? " — activity unknown" : ""}`);
1733
+ for (const n of plan.notes) console.log(` note: ${n}`);
1734
+ console.log(`apply with: oats instance stop ${name} --apply --plan-revision ${plan.planRevision} --idempotency-key <key>`);
1735
+ return;
1736
+ }
1737
+ const rev = flag("plan-revision"), key = flag("idempotency-key"), grace = flag("grace-ms");
1738
+ if (rev === true || key === true || grace === true) return bail("E_BAD_ARGS", usage);
1739
+ const receipt = applyStop(dirFlag(), root, name, { home: homeOpt, recursive, planRevision: rev, idempotencyKey: key, ...(grace !== undefined ? { graceMs: Number(grace) } : {}) });
1740
+ if (JSON_MODE) { jsonOk(receipt); return; }
1741
+ for (const r of receipt.results) console.log(` ${r.instance}: ${r.ok ? (r.stopped ? "stopped" : `already ${r.state}`) : `${r.code} — ${r.message}`}`);
1742
+ console.log(receipt.ok ? `stopped${receipt.replayed ? " (replayed receipt)" : ""}; home, work, transcript and launch configuration retained — restart with \`oats session restart\`` : "some targets are still running; nothing was escalated");
1743
+ if (!receipt.ok) process.exit(1);
1956
1744
  return;
1957
- }
1958
- cmdFail("E_UNKNOWN_CAPABILITY", `unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
1959
- }
1960
- if (layer && manifest.layer !== layer) cmdFail("E_LAYER_MISMATCH", `capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
1961
- const targets = [["agent-types", flag("type")], ["souls", flag("soul")]].filter(([, value]) => value);
1962
- if (args.includes("--global")) targets.push(["global", undefined]);
1963
- if (targets.length > 1) cmdFail("E_BAD_ARGS", "choose exactly one of --global, --type, or --soul");
1964
- const [targetKind, targetName] = targets[0] || ["global", undefined];
1965
- const targetLabel = targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"}:${targetName}`;
1966
- const soulForEffective = targetKind === "souls" ? targetName : undefined;
1967
- const enabled = !args.includes("--disable");
1968
- const bindingOf = (e) => (!e ? undefined : targetKind === "global" ? e.global : e[targetKind]?.[targetName]);
1969
- const stateOf = (e) => ({ bound: bindingOf(e) !== undefined, enabled: bindingOf(e) === undefined ? null : (typeof bindingOf(e) === "object" ? bindingOf(e).enabled !== false : !!bindingOf(e)), settings: e?.settings && typeof e.settings === "object" ? { ...e.settings } : {} });
1970
- // --inherit removes THIS level's binding for the addressed target (and the
1971
- // whole entry once no target is left), so outer scopes and targets apply
1972
- // again. Distinct from --disable, which writes an explicit exclusion.
1973
- if (inherit) {
1974
- const existing = manifest.layer ? caps.layers[manifest.layer] : caps.additive[manifest.capability];
1975
- const entry0 = existing && existing !== "none" && (!manifest.layer || existing.capability === manifest.capability) ? existing : undefined;
1976
- const before = stateOf(entry0);
1977
- if (!entry0 || !before.bound) cmdFail("E_NOT_BOUND", `${manifest.capability} has no ${targetLabel} binding at ${level} level (${shortPath(file)}); nothing to inherit from`);
1978
- // Only the addressed target goes. Every other binding the entry carries
1979
- // (an explicit global, other souls, types) is policy this command cannot
1980
- // tell from intent, so it stays; the receipt names what still applies.
1981
- if (targetKind === "global") delete entry0.global;
1982
- else { delete entry0[targetKind][targetName]; if (!Object.keys(entry0[targetKind]).length) delete entry0[targetKind]; }
1983
- const remaining = [...(entry0.global !== undefined ? [`global: ${entry0.global}`] : []), ...Object.entries(entry0["agent-types"] || {}).map(([t, v]) => `type:${t}: ${JSON.stringify(v)}`), ...Object.entries(entry0.souls || {}).map(([n, v]) => `soul:${n}: ${JSON.stringify(v)}`)];
1984
- const targetsLeft = remaining.length > 0;
1985
- if (!targetsLeft) { if (manifest.layer) delete caps.layers[manifest.layer]; else delete caps.additive[manifest.capability]; }
1986
- writeFileSync(file, replaceCapabilitiesBlock(text, caps));
1987
- const note = targetsLeft ? `this level still binds ${manifest.capability}: ${remaining.join(", ")}; remove them with oats use ${manifest.capability} --inherit --global|--type <t>|--soul <s> if the intent is full inheritance` : null;
1988
- answer({ capability: manifest.capability, action: "inherit", target: targetLabel, layer: manifest.layer || null, level, file, entryRemoved: !targetsLeft, remaining, note, before, after: { bound: false, enabled: null, settings: targetsLeft ? before.settings : {}, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) } },
1989
- `${manifest.capability} ${targetLabel} binding removed at ${level} level${targetsLeft ? `; still bound here: ${remaining.join(", ")}` : " (entry removed)"} (${shortPath(file)})`);
1990
- return;
1745
+ } catch (e) { return bail(e.code || "E_LIFECYCLE_FAILED", e.message, e.plan ? { plan: e.plan } : e.candidates ? { candidates: e.candidates } : undefined); }
1991
1746
  }
1992
- // Locate or create the entry in the right subtree.
1993
- let entry;
1994
- if (manifest.layer) {
1995
- const existing = caps.layers[manifest.layer];
1996
- var entryExisted = !!(existing && existing !== "none" && existing.capability === manifest.capability);
1997
- entry = entryExisted ? existing : { capability: manifest.capability };
1998
- // One entry per layer per level: another capability's entry is never
1999
- // overwritten, not even by an exclusion, and the remedy is exact.
2000
- if (existing && existing !== "none" && existing.capability !== manifest.capability) {
2001
- cmdFail("E_LAYER_BOUND", `fundamental layer ${manifest.layer} already binds ${existing.capability} at ${level} level (${shortPath(file)}); remove that binding first with oats use ${existing.capability} --inherit --global (and --type/--soul for each of its targets), or disable the layer here with oats use none --layer ${manifest.layer}, then use ${manifest.capability}`);
2002
- }
2003
- caps.layers[manifest.layer] = entry;
2004
- } else {
2005
- entry = caps.additive[manifest.capability] || {};
2006
- caps.additive[manifest.capability] = entry;
2007
- }
2008
- const beforeState = stateOf(entryExisted || !manifest.layer ? entry : undefined);
2009
- const from = originToFrom(manifest._origin);
2010
- if (from && !entry.from) entry.from = from;
2011
- const settingsArgs = [];
2012
- for (let i = 0; i < args.length; i++) {
2013
- if (args[i] !== "--settings") continue;
2014
- let consumed = 0;
2015
- for (let j = i + 1; j < args.length && !args[j].startsWith("--"); j++, consumed++) settingsArgs.push(args[j]);
2016
- if (!consumed) cmdFail("E_BAD_ARGS", "--settings expects one or more key=value pairs");
2017
- i += consumed;
2018
- }
2019
- if (settingsArgs.length) {
2020
- entry.settings = entry.settings && typeof entry.settings === "object" ? entry.settings : {};
2021
- for (const kv of settingsArgs) {
2022
- const eq = kv.indexOf("=");
2023
- if (eq <= 0) cmdFail("E_BAD_ARGS", `--settings expects key=value, got "${kv}"`);
2024
- // WRITE side of the refusals the readers enforce. Two distinct hazards on
2025
- // this one line:
2026
- // - `--settings __proto__=x` assigned through the inherited setter,
2027
- // which swallowed the entry, and the command reported success for a
2028
- // setting it never wrote;
2029
- // - the VALUE is rendered verbatim into one `key: value` line, so a
2030
- // newline-bearing value stopped being a value and became document —
2031
- // a crafted one added a whole second capability entry.
2032
- // Both fail closed, before anything is written.
2033
- const key = assertSafeConfigWriteKey(kv.slice(0, eq), `--settings key ${JSON.stringify(kv.slice(0, eq))}`);
2034
- entry.settings[key] = assertSafeConfigValue(kv.slice(eq + 1), `--settings value for ${JSON.stringify(key)}`);
1747
+ let home = flag("home");
1748
+ if (home === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1749
+ if (home !== undefined && !isAbsolute(home)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1750
+ if (home === undefined) {
1751
+ let root;
1752
+ try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1753
+ let r; try { r = resolveOatsConfig(dirFlag()); } catch (e) { return bail(e.code || "E_CONFIG_BROKEN", e.message); }
1754
+ const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])].map((p) => realOrResolved(p)))];
1755
+ const candidates = [];
1756
+ for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
1757
+ if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
1758
+ if (candidates.length > 1) return bail("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`, { candidates });
1759
+ home = candidates[0].home;
1760
+ } else if (basename(home) !== name) return bail("E_HOME_MISMATCH", `--home ${home} is not the home of instance ${JSON.stringify(name)}`);
1761
+ try {
1762
+ if (sub === "git") {
1763
+ const observed = observeInstanceGit(home);
1764
+ if (JSON_MODE) { jsonOk(observed); return; }
1765
+ const o = observed.observation;
1766
+ console.log(`${observed.instance} — ${shortPath(o.worktree)} @ ${o.branch ?? (o.detached ? `detached ${o.revision.slice(0, 12)}` : "unborn")}`);
1767
+ console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
1768
+ console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
1769
+ console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
1770
+ for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
1771
+ for (const n of observed.notes) console.log(` note: ${n}`);
1772
+ return;
2035
1773
  }
1774
+ const fileId = flag("file"), revision = flag("revision"), indexRevision = flag("index-revision");
1775
+ if (fileId === true || revision === true || indexRevision === true) return bail("E_BAD_ARGS", usage);
1776
+ const d = diffInstanceFile(home, { fileId, revision, indexRevision });
1777
+ if (JSON_MODE) { jsonOk(d); return; }
1778
+ console.log(`${d.file.origPath ? `${d.file.origPath} -> ` : ""}${d.file.path} (${d.file.kind}, against ${d.against})${d.binary ? " [binary]" : ""}${d.truncated ? ` [truncated at ${d.limit} bytes]` : ""}`);
1779
+ if (!d.binary) process.stdout.write(d.patch);
1780
+ } catch (e) {
1781
+ bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
2036
1782
  }
2037
- if (targetKind === "global") entry.global = enabled;
2038
- else {
2039
- // An EXISTING layer entry with no explicit targets is implicitly global:
2040
- // materialize that before narrowing, so adding a soul/type binding does not
2041
- // silently drop everyone else. An entry this command just created (the
2042
- // layer was `none` or another capability) has no implicit global to keep:
2043
- // a targeted first binding is written as global: false, explicitly.
2044
- if (manifest.layer && entry.global === undefined && !entry["agent-types"] && !entry.souls) entry.global = entryExisted;
2045
- entry[targetKind] = entry[targetKind] && typeof entry[targetKind] === "object" ? entry[targetKind] : {};
2046
- // Same write-side refusal, and for the same two reasons: `--soul
2047
- // __proto__` was swallowed by the inherited setter and reported as
2048
- // activated, and a `--soul`/`--type` NAME is written as a mapping key, so a
2049
- // newline in it injects document exactly like a settings value does.
2050
- entry[targetKind][assertSafeConfigWriteKey(targetName, `--${targetKind === "agent-types" ? "type" : "soul"} name ${JSON.stringify(String(targetName))}`)] = enabled;
2051
- }
2052
- writeFileSync(file, replaceCapabilitiesBlock(text, caps));
2053
- const afterState = stateOf(entry);
2054
- const missing = capabilityMissingRequires(manifest.capability, dir);
2055
- answer({ capability: manifest.capability, action: enabled ? "enable" : "disable", target: targetLabel, layer: manifest.layer || null, level, file, settings: entry.settings || {}, before: beforeState, after: { ...afterState, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) }, missingRequires: missing },
2056
- `${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
2057
- if (JSON_MODE) return;
2058
- for (const miss of missing) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
2059
- console.log("New instances receive the resolved capability; committed souls are unchanged.");
2060
1783
  }
2061
-
2062
- // ---------- install / trust / list / remove / migrate ----------
2063
- const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2064
- /** `oats install <source>`: distribution-package acquisition (exact-lock closure,
2065
- * activates nothing). Marketplace capability ids keep the legacy capability path
2066
- * until workstream 3 publishes the official packages. */
2067
- function install() {
2068
- const src = args[1];
2069
- const dir = dirFlag();
2070
- if (!src || src.startsWith("--")) {
2071
- // Usage errors surface BEFORE any restore/network side effect: a malformed
2072
- // --accept-requirement must not mutate the deployment and then report E_USAGE.
2073
- flagAll("accept-requirement");
2074
- reconcile(dir);
2075
- return;
2076
- }
2077
- const retiredReason = retiredCapabilityReason(src);
2078
- if (retiredReason) cmdFail("retired-capability", retiredReason);
2079
- // Package source? (git/path with an oats-package.json, or a catalog id) — otherwise legacy capability acquisition.
2080
- let parsedSrc;
2081
- try { parsedSrc = parsePackageSource(src); } catch { parsedSrc = undefined; }
2082
- const catalogId = parsedSrc?.kind === "catalog" ? parsedSrc.id : undefined;
2083
- const hasOfficialPackage = !!catalogId && Object.hasOwn(officialPackageCatalog(), catalogId);
2084
- // Once an official package catalog entry exists it becomes the default
2085
- // acquisition route for that short id. Existing v1 installs keep working,
2086
- // but a deliberate `oats install oats.okf` now acquires the package rather than
2087
- // creating another legacy capability lock.
2088
- const isMarketplaceCap = parsedSrc?.kind === "catalog" && !!marketplaceCapabilities()[catalogId] && !hasOfficialPackage;
2089
- const isLocalPackage = parsedSrc?.kind === "path" && existsSync(join(parsedSrc.path, "oats-package.json"));
2090
- const isCatalogPackage = parsedSrc?.kind === "catalog" && !isMarketplaceCap;
2091
- let gitInspection;
2092
- if (parsedSrc && (parsedSrc.kind === "git" || isLocalPackage || isCatalogPackage)) {
2093
- // Remote Git may be either a distribution package or the documented
2094
- // legacy standalone-capability repository. Inspect the fetched ROOT before
2095
- // any scope lock preflight; never infer root layout from closure errors.
2096
- if (parsedSrc.kind === "git") {
2097
- try { gitInspection = inspectGitSourceRoot(src); }
2098
- catch (e) { cmdFail(e.code || "invalid-source", e.message || e); return; }
2099
- if (gitInspection.payloadPackage) {
2100
- try { installPackage(dir, src, { rootSnapshot: gitInspection }); }
2101
- finally { gitInspection.cleanup(); }
2102
- return;
2103
- }
2104
- // Legacy standalone-capability repositories predate contained package
2105
- // roots, so the fallback only applies to a REPOSITORY-ROOT capability
2106
- // that was not asked for a specific path. A repo whose root carries
2107
- // oats-package.json must never silently downgrade to capability
2108
- // acquisition just because the selected path holds no package.
2109
- if (gitInspection.explicitPath || gitInspection.package || !gitInspection.capability) {
2110
- const where = `package path "${gitInspection.path}"`;
2111
- const reason = gitInspection.package
2112
- ? `Git source ${src} has an oats-package.json at the repository ROOT but no package at ${where}${gitInspection.explicitPath ? "" : " (the default)"} — select the root explicitly with \`${src}#.\``
2113
- : gitInspection.explicitPath
2114
- ? `Git source ${src} has no oats-package.json at ${where}`
2115
- : `Git source ${src} has no oats-package.json at ${where} (the default package path) and no oats.json at its root`;
2116
- gitInspection.cleanup();
2117
- cmdFail("invalid-package-manifest", reason); return;
2118
- }
2119
- // Standalone capability: hand the SAME fetched snapshot to legacy
2120
- // acquisition (which re-verifies that exact root layout before copying).
2121
- } else { installPackage(dir, src); return; }
2122
- }
2123
- let known;
2124
- try { known = gitInspection ? undefined : capabilityManifest(src, dir); }
2125
- catch (e) { gitInspection?.cleanup(); cmdFail(e.code || "invalid-lock", e.message || e); return; }
2126
- if (known) {
2127
- if (JSON_MODE) { jsonOk({ alreadyAcquired: known.capability, version: known.version || null }); return; }
2128
- console.log(`Already acquired capability ${known.capability} (${known.version || "unversioned"}); not activated or updated.`);
2129
- return;
1784
+ /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
1785
+ function readinessCmd() {
1786
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1787
+ dropAmbientRoot();
1788
+ // A captured incarnation's readiness comes from its retained resolution, not
1789
+ // from the current configuration this command reads; refuse before inspecting.
1790
+ const homeArg = flag("home");
1791
+ if (homeArg && homeArg !== true) {
1792
+ let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
1793
+ if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
2130
1794
  }
2131
- let r;
2132
- try { r = acquireCapability(dir, src, { rootSnapshot: gitInspection }); }
2133
- catch (e) { cmdFail(e.code || "invalid-source", e.message); return; }
2134
- finally { gitInspection?.cleanup(); }
2135
- const lock = {
2136
- source: r.source,
2137
- version: r.manifest.version || null,
2138
- ...(r.commit ? { commit: r.commit } : {}), integrity: r.integrity,
2139
- // Marketplace packages ship with the kernel you already installed — they are
2140
- // trusted at acquisition; third-party git/path installs need explicit `oats trust`.
2141
- trustedExecutables: !!r.marketplace,
2142
- };
2143
- if (r.marketplace && r.manifest.environment?.length) {
2144
- (JSON_MODE ? console.error : console.log)(`Requested launch environment: ${r.manifest.environment.join(", ")}`);
1795
+ const inspect = computeInspect({ onFail: bail });
1796
+ if (!inspect) return;
1797
+ const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
1798
+ const verify = args.includes("--verify-signatures");
1799
+ let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
1800
+ const deploymentDir = inspect.scope?.context ?? null;
1801
+ // Echo the exact selector this read was made with, so a consumer can bind the
1802
+ // result to its own admitted target without inventing a revision.
1803
+ // Every field is the argument AS GIVEN (no realpath): a consumer compares it
1804
+ // byte-exact with what it sent. The canonical scope is subject.context.
1805
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
1806
+ const agentsRootArg = given("agents-root"), dirArg = given("dir");
1807
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
1808
+ : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
1809
+ : { kind: "scope", dir: dirArg };
1810
+ const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
1811
+ if (args.includes("--policy")) {
1812
+ const homeOpt = flag("home");
1813
+ let meta = null;
1814
+ if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
1815
+ readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
1816
+ readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
2145
1817
  }
2146
- let lockFile;
2147
- try { lockFile = writeCapabilityLock(dir, r.manifest.capability, lock); }
2148
- catch (e) {
2149
- // Refused lock write (e.g. legacy-lock: a converted scope rejects a NEW v1
2150
- // capability entry) must
2151
- // not strand the acquired artifact — compensate before failing.
2152
- rmSync(r.dest, { recursive: true, force: true });
2153
- cmdFail(e.code || "legacy-lock", e.message); return;
2154
- }
2155
- if (JSON_MODE) { jsonOk({ capability: r.manifest.capability, version: r.manifest.version || null, integrity: r.integrity, source: r.source, dir: r.dest, lockFile, marketplace: !!r.marketplace, trustedExecutables: !!r.marketplace }); return; }
2156
- console.log(`Acquired ${r.manifest.capability} → ${shortPath(r.dest)}`);
2157
- console.log(`Locked ${r.manifest.version || r.commit || "exact artifact"} (${r.integrity}) in ${shortPath(lockFile)}; not activated.`);
2158
- if (r.marketplace) console.log("Marketplace package: executables trusted at acquisition.");
2159
- else if (r.manifest.commands || r.manifest.hooks || r.manifest.environment?.length) {
2160
- if (r.manifest.environment?.length) console.log(`Future trust request includes launch environment: ${r.manifest.environment.join(", ")}`);
2161
- console.log(`Executable surface is blocked until: oats trust ${r.manifest.capability} --dir ${shortPath(dir)}`);
1818
+ if (JSON_MODE) { jsonOk(readiness); return; }
1819
+ console.log(`readiness — ${readiness.subject.kind === "soul" ? `soul ${readiness.subject.name}` : shortPath(readiness.subject.context)}: ${readiness.summary.ready ? "READY" : `${readiness.summary.fail} failing, ${readiness.summary.unknown} unknown of ${readiness.summary.required} required`}`);
1820
+ for (const [name, check] of Object.entries(readiness.checks)) {
1821
+ console.log(` ${name}: ${check.status}`);
1822
+ for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.signature ? ` · signature ${i.signature.status}${i.signature.signer?.label ? ` by ${i.signature.signer.label}` : ""}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
2162
1823
  }
1824
+ if (readiness.policy) console.log(` policy: child spawns ${readiness.policy.childSpawns.allowed ? "allowed" : "disabled"} (${readiness.policy.childSpawns.origin.kind}${readiness.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${readiness.policy.worktrees.allowed === null ? "unknown" : readiness.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
1825
+ for (const n of readiness.notes) console.log(` note: ${n}`);
2163
1826
  }
1827
+ // ---------- workspace model v2: sync / package / workspace status / capabilities / souls ----------
1828
+ // Contract: docs/design/2026-09-23-workspace-module-contracts.md §6. Nothing is
1829
+ // installed: `oats-local.yaml` names the workspace, discovery runs over the Git
1830
+ // remotes (lib/workspace.mjs), packages resolve to exact commits (lib/packages.mjs,
1831
+ // lock v3) and the only persisted state is `oats-lock.json` beside oats-local.yaml.
2164
1832
 
2165
- /** Lock-file levels from dir upward (closest last — outermost first), like restoreCapabilities' walk. */
2166
- function lockLevelsUp(dir) {
2167
- const levels = [];
2168
- for (let d = resolve(dir); ; d = dirname(d)) {
2169
- if (existsSync(join(d, OATS_LOCK_FILE))) levels.push(d);
2170
- if (dirname(d) === d) break;
2171
- }
2172
- return levels.reverse();
1833
+ /** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
1834
+ * the content-addressed fetch cache (tests never touch ~/.cache). */
1835
+ function remoteOptionsFromEnv() {
1836
+ const cacheDir = process.env.OATS_REMOTE_CACHE;
1837
+ return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
2173
1838
  }
2174
1839
 
2175
- /** Check/restore one level's v2 package locks via the ENGINE's restorePackages
2176
- * (exact restore, no ref advancement, staging + integrity/capability/deps
2177
- * verification inside). The engine walks the lock chain from the given dir;
2178
- * reconciliation calls it per deduplicated level and keeps that level's rows. */
2179
- /** Map engine restore rows to WS2 report items (kind package). */
2180
- const pkgRow = (r) => ({
2181
- id: r.package, level: r.level, package: true, dir: r.dir,
2182
- status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
2183
- });
2184
-
2185
- /** Restore-and-partition for reconciliation (reviewer-455ba15 fix 1): the
2186
- * engine's restorePackages walks the WHOLE lock chain from a directory and has
2187
- * no exact-level option, so invoke it ONCE per deepest scope and PARTITION the
2188
- * report rows by lock level — never re-invoke per level (each re-invocation
2189
- * re-runs restore side effects for every ancestor lock). Returns a Map
2190
- * level(resolved) → rows. */
2191
- function partitionedPackageRestore(deepestDir) {
2192
- const byLevel = new Map();
2193
- const add = (level, row) => {
2194
- const key = resolve(level);
2195
- if (!byLevel.has(key)) byLevel.set(key, []);
2196
- byLevel.get(key).push(row);
2197
- };
2198
- for (const r of restorePackages(deepestDir)) add(r.level, pkgRow(r));
2199
- // EMPTY v1 lock files surface too (maintainer ruling): the engine's restore
2200
- // report only rows NON-empty v1 files. Walk the raw lock chain (a lock-only
2201
- // scope has no config, so configChain-based reads cannot see it) and emit a
2202
- // LEGACY row for each empty v1 file so reconciliation shows the pending
2203
- // lock-format migration.
2204
- for (const level of lockLevelsUp(deepestDir)) {
2205
- try {
2206
- const parsed = JSON.parse(readFileSync(join(level, OATS_LOCK_FILE), "utf8"));
2207
- if (parsed.lockfileVersion !== 2 && !Object.keys(parsed.capabilities || {}).length) {
2208
- add(level, { id: null, level, package: true, status: "legacy", reason: `empty lockfileVersion ${parsed.lockfileVersion ?? 1} file — pending lock-format migration: oats migrate --dir ${level}` });
2209
- }
2210
- } catch { /* malformed locks raise via restorePackages above */ }
1840
+ /** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
1841
+ function workspaceContext(bail) {
1842
+ const dir = dirFlag();
1843
+ let found;
1844
+ try { found = loadLocal(dir); }
1845
+ catch (e) { return bail(e.code || "E_LOCAL_MISSING", e.message, e.details); }
1846
+ return { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
1847
+ }
1848
+
1849
+ /** The official catalog's package map (package-catalog.json; OATS_PACKAGE_CATALOG overrides). */
1850
+ function catalogForSync(bail) {
1851
+ try { return officialPackageCatalog(); }
1852
+ catch (e) { return bail(e.code || "E_PACKAGE_MISSING", e.message); }
1853
+ }
1854
+
1855
+ /** The package requests of a standalone view: the catalog's own pin of the package
1856
+ * providing oats.core (decision 25) — nothing else, since the version list lives in
1857
+ * the workspace file we cannot read. The request is written as resolvePackages
1858
+ * expects a CATALOG entry: the bare version (`v1.1.3`), never the catalog's raw ref
1859
+ * (`oats-framework/v1.1.3` is a tag PATH the one grammar refuses); parsePackageRequest
1860
+ * recomposes the tag from the catalog's own convention.
1861
+ * → { packages: { <id>: <version> }, problems: [ { code: "E_PACKAGE_MISSING", … } ] } */
1862
+ function standalonePackages(catalog) {
1863
+ let id = "oats.framework", file = process.env.OATS_PACKAGE_CATALOG || null;
1864
+ try { const d = describeOfficialCatalog(); file = d.catalog.file; id = d.capabilityAliases.find((a) => a.capability === "oats.core")?.package ?? id; } catch { /* the catalog is diagnosed below */ }
1865
+ const version = standaloneCatalogVersion(catalog?.[id]?.ref);
1866
+ if (version) return { packages: { [id]: version }, problems: [] };
1867
+ const why = catalog?.[id] ? `its ref ${JSON.stringify(catalog[id].ref)} carries no version` : `it has no entry ${JSON.stringify(id)}`;
1868
+ return { packages: {}, problems: [{ code: "E_PACKAGE_MISSING", id, reason: "no-catalog", catalog: file, path: `/packages/${id}`, message: `the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}): ${why}; standalone spawns will be refused until it does` }] };
1869
+ }
1870
+ /** The bare version of a catalog ref: its LAST path segment when that is a version
1871
+ * (`v1.1.3` → `v1.1.3`; `oats-framework/v1.1.3` → `v1.1.3`; `main` → null). */
1872
+ function standaloneCatalogVersion(ref) {
1873
+ if (typeof ref !== "string" || !ref.trim()) return null;
1874
+ const tail = ref.trim().split("/").filter(Boolean).pop() ?? "";
1875
+ return classifyPackageValue(tail).kind === "catalog" ? tail : null;
1876
+ }
1877
+
1878
+ /** Discover the workspace named by oats-local.yaml over the real remote. */
1879
+ async function discoverForCli(ctx, bail) {
1880
+ // The standalone case (decisions 10/25) is a discovery too: the repo's own view
1881
+ // plus the kernel's oats.core default — discoverOrStandalone decides.
1882
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { remoteOptions: ctx.remoteOptions }); }
1883
+ catch (e) {
1884
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1885
+ throw e;
2211
1886
  }
2212
- return byLevel;
2213
1887
  }
2214
1888
 
2215
- function installPackage(dir, src, opts = {}) {
2216
- const bail = (e) => (JSON_MODE ? jsonFail(e.code || "invalid-source", e.message || e) : die(e.message || e));
2217
- let r;
2218
- try { r = acquirePackage(dir, src, opts); }
2219
- catch (e) { bail(e); return true; }
2220
- // Packages are transport; capabilities are what lands on disk. Report both,
2221
- // and let the CAPABILITY rows carry the provenance an operator acts on.
2222
- if (JSON_MODE) { jsonOk({ root: r.root, installed: r.installed, capabilities: r.capabilities, lockFile: r.lockFile, depWarnings: r.depWarnings || [] }); return true; }
2223
- for (const p of r.installed) {
2224
- console.log(`${p.kept ? "ok " : "Acquired "}${p.package}@${p.version}`);
2225
- console.log(` locked ${p.commit === "local" ? "local tree" : p.commit} at path ${p.path} (${p.integrity})`);
2226
- for (const c of r.capabilities.filter((x) => x.package === p.package)) {
2227
- console.log(` capability ${c.capability}@${c.version}${c.layer ? ` layer: ${c.layer}` : ""} → ${shortPath(c.dir)} (${c.integrity})`);
2228
- }
2229
- if (!r.capabilities.some((x) => x.package === p.package)) console.log(" capabilities: (none)");
2230
- }
2231
- for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
2232
- console.log(`Locked in ${shortPath(r.lockFile)}; nothing activated.`);
2233
- // Read the executable surface off the ENGINE's projection, not a config-chain
2234
- // manifest lookup: at a scope with no config yet, that lookup sees nothing.
2235
- const executables = r.capabilities
2236
- .filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
2237
- .map((c) => c.capability);
2238
- if (executables.length) console.log(`Executable surfaces blocked until trusted: ${executables.map((c) => `oats trust ${c}`).join("; ")}`);
2239
- return true;
1889
+ const short = (oid) => (typeof oid === "string" ? oid.slice(0, 8) : "?");
1890
+ /** Where a reader takes capability manifests from: the instance home's own
1891
+ * materialized modules when the home has them (workspace model), else the
1892
+ * context directory (classic chain). */
1893
+ const manifestSource = (meta, home, ctx) => (meta && meta.modules && typeof meta.modules === "object" && home ? realOrResolved(home) : ctx);
1894
+ /** Display name of a discovery: the workspace's name, or the standalone label (decision 10). */
1895
+ const workspaceName = (discovery) => discovery.workspace?.name ?? `standalone:${memberLabel(discovery.key)}`;
1896
+ const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || String(key);
1897
+ const teamLabel = (team) => team ?? "unassigned";
1898
+ const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1899
+
1900
+ /** Rows of every non-private soul/capability of confirmed members + locked package capabilities. */
1901
+ function workspaceItems(discovery, lock, { includePrivate = false } = {}) {
1902
+ const souls = [];
1903
+ const capabilities = [];
1904
+ for (const m of discovery.members) {
1905
+ if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
1906
+ for (const s of m.souls) if (includePrivate || !s.private) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1907
+ for (const c of m.capabilities) if (includePrivate || !c.private) capabilities.push({ name: c.name, origin: originOf(c), kind: "member", repoKey: c.repoKey, commit: c.commit, team: teamLabel(c.team), private: c.private, path: c.path, layer: c.manifest.layer ?? null, version: c.manifest.version ?? null });
1908
+ }
1909
+ for (const ext of discovery.external || []) {
1910
+ const s = ext.soul;
1911
+ if (includePrivate || !s.private) souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
1912
+ }
1913
+ for (const [id, entry] of Object.entries(lock?.packages || {})) {
1914
+ 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, approved: !!entry.approved });
1915
+ }
1916
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
1917
+ souls.sort(byName);
1918
+ capabilities.sort(byName);
1919
+ return { souls, capabilities };
2240
1920
  }
2241
1921
 
2242
- /** Bare `oats install` chain restore: engine packages (lock v2) + legacy locked
2243
- * capabilities (v1). Returns { report, failed }; output goes to stdout (human)
2244
- * or stderr (JSON mode) — the reconcile envelope owns stdout in JSON mode. */
2245
- function restore(dir) {
2246
- const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2247
- // Fail-closed locks: restorePackages/restoreCapabilities RAISE typed
2248
- // invalid-lock — let the reconcile boundary surface the code verbatim
2249
- // (never softened to empty); this throw is caught by reconcile().
2250
- const pkgReport = restorePackages(dir).map((r) => ({
2251
- id: r.package, level: r.level, package: true, dir: r.dir,
2252
- status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
1922
+ /** Membership rows for `sync` / `workspace status`. */
1923
+ function memberRows(discovery) {
1924
+ return discovery.members.map((m) => ({
1925
+ key: m.key, name: memberLabel(m.key), commit: m.commit ?? null, confirmed: m.confirmed, status: m.confirmed ? "confirmed" : m.reason, detail: m.detail ?? null, team: m.team ?? null,
1926
+ souls: m.souls.map((s) => s.name), capabilities: m.capabilities.map((c) => c.name), publishes: m.publishes ?? null,
2253
1927
  }));
2254
- const report = [...restoreCapabilities(dir), ...pkgReport];
2255
- if (!report.length) note("Nothing to restore — no locked capabilities in the config chain.");
2256
- let failed = 0;
2257
- for (const r of report) {
2258
- const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
2259
- if (r.status === "present") note(`ok ${what} (${shortPath(r.dir)})`);
2260
- else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)}${r.integrity ? ` (${r.integrity})` : ""}`);
2261
- else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
2262
- else if (r.status === "retired") { failed++; note(`RETIRED ${what} ${r.reason}`); }
2263
- else { failed++; note(`FAILED ${what} ${r.reason}`); }
2264
- }
2265
- return { report, failed };
2266
1928
  }
2267
1929
 
2268
- /** Unsuccessful restore statuses and their frozen taxonomy codes (reviewer-6f0a3bd:
2269
- * "unrestorable" and "retired" must not report ok). */
2270
- const UNSUCCESSFUL_RESTORE = { failed: undefined, unrestorable: "invalid-source", retired: "retired-capability" };
1930
+ /** Package rows for `sync` / `workspace status` from the lock. */
1931
+ function packageRows(lock) {
1932
+ return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
1933
+ }
2271
1934
 
2272
- /** One artifact report item → the machine shape (kind capability|package). */
2273
- const artifactJson = (r) => ({
2274
- id: r.id, kind: r.package ? "package" : "capability", level: r.level,
2275
- status: r.status, ...(r.dir ? { dir: r.dir } : {}), ...(r.reason ? { reason: r.reason } : {}),
2276
- ...(Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status) ? { code: r.code || UNSUCCESSFUL_RESTORE[r.status] || "integrity-drift" } : {}),
2277
- });
1935
+ /** Print a padded table: rows are arrays of strings. */
1936
+ function printTable(header, rows) {
1937
+ const all = [header, ...rows];
1938
+ const widths = header.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? "").length)));
1939
+ for (const r of all) console.log(" " + r.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1940
+ }
2278
1941
 
2279
- /** Emit the reconcile/restore result: human exit or the single-envelope JSON contract.
2280
- * Full success → { ok: true, result }. ANY artifact or consented-install failure →
2281
- * nonzero with error.code E_RECONCILE_FAILED and the SAME complete report under
2282
- * error.details — partial outcomes are never lost. */
2283
- function emitReconcileResult({ boundary, boundaryKind, scopes, requirements, failures }) {
2284
- const result = { boundary, boundaryKind, scopes, requirements, failures };
2285
- if (JSON_MODE) {
2286
- if (failures.length) {
2287
- console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_RECONCILE_FAILED", message: `${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`, details: result } }));
2288
- process.exit(1);
2289
- }
2290
- jsonOk(result);
2291
- return;
2292
- }
2293
- if (failures.length) {
2294
- console.log("\nFailures by scope:");
2295
- for (const f of failures) console.log(` ${shortPath(f.scope)}: ${f.id} — ${f.reason}`);
2296
- die(`${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`);
2297
- }
1942
+ /** Executables digest of a locked package, read over the remote at its locked commit. */
1943
+ async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOptions) {
1944
+ const req = parsePackageRequest(id, workspace.packages[id], catalog);
1945
+ const tree = await readPackageTree(remoteModule, req.remoteRef, entry.commit, entry.path, { remoteOptions });
1946
+ const targets = tree.manifests.flatMap((m) => manifestExecutables(m.manifest).map((x) => `${m.name}: ${x.kind} ${x.name} → ${x.target}`));
1947
+ return { digest: executablesDigest(tree), targets };
2298
1948
  }
2299
1949
 
2300
- /** Bare `oats install` at a team boundary: reconcile the whole workspace — restore the
2301
- * boundary scope's graph (its ancestor chain), then every descendant scope's own
2302
- * lock graph EXACTLY ONCE, in deterministic path order, with pruned discovery;
2303
- * verify v2 package locks against the installed package store; validate
2304
- * config-referenced capabilities against visible locked packages; aggregate
2305
- * missing requirements and failures by scope.
2306
- * Non-team scopes keep current-chain behavior unless --recursive names a boundary. */
2307
- /** Bare `oats install` (no source): current-chain restore or team-boundary
2308
- * reconciliation. JSON-mode boundary: ANY throw before emitReconcileResult
2309
- * (malformed lock/config, discovery failures) must still yield the single
2310
- * envelope — never empty stdout with a stack trace. */
2311
- function reconcile(dir) {
2312
- try { reconcileInner(dir); }
1950
+ /** One yes/no question on the terminal (TTY only; the caller checks). */
1951
+ async function askYesNo(question) {
1952
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1953
+ try {
1954
+ const answer = (await rl.question(question)).trim().toLowerCase();
1955
+ return answer === "y" || answer === "yes";
1956
+ } finally { rl.close(); }
1957
+ }
1958
+
1959
+ /** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
1960
+ * way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
1961
+ * `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
1962
+ * `bail` never returns (it exits the process with the caller's error shape).
1963
+ * → { report, lock, discovery, approvalNeeded, interactive, items, lockFile } */
1964
+ async function performSync(ctx, bail, { onDiscovered } = {}) {
1965
+ const catalog = catalogForSync(bail);
1966
+ const discovery = await discoverForCli(ctx, bail);
1967
+ onDiscovered?.(discovery);
1968
+ let previous;
1969
+ try { previous = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1970
+ let resolved;
1971
+ // Standalone: no workspace file → the only package request is the kernel's
1972
+ // default, at the catalog's pinned version (decision 25). A catalog that cannot
1973
+ // name it is a PROBLEM of this sync (reported, exit unchanged), not a silent empty lock.
1974
+ const standalone = discovery.standalone === true ? standalonePackages(catalog) : null;
1975
+ const packageSource = standalone ? { packages: standalone.packages } : discovery.workspace;
1976
+ const problems = [...discovery.problems, ...(standalone?.problems ?? [])];
1977
+ try { resolved = await resolvePackages(packageSource, { catalog, lock: previous, remoteOptions: ctx.remoteOptions }); }
2313
1978
  catch (e) {
2314
- if (JSON_MODE) {
2315
- console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: e.code || "E_RECONCILE_FAILED", message: String(e.message || e) } }));
2316
- process.exit(1);
1979
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1980
+ throw e;
1981
+ }
1982
+ let lock = resolved.lock;
1983
+ const approvalNeeded = [];
1984
+ const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
1985
+ for (const id of Object.keys(lock.packages)) {
1986
+ const entry = lock.packages[id];
1987
+ if (entry.approved) continue;
1988
+ let digest, targets;
1989
+ try { ({ digest, targets } = await lockedExecutablesDigest(id, entry, packageSource, catalog, ctx.remoteOptions)); }
1990
+ catch (e) {
1991
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1992
+ throw e;
2317
1993
  }
2318
- die(e.message || e);
1994
+ if (interactive) {
1995
+ console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
1996
+ for (const t of targets) console.error(` ${t}`);
1997
+ if (targets.length === 0) console.error(" (no commands or hooks — nothing runs unattended)");
1998
+ if (await askYesNo(`approve ${id} ${entry.version}? [y/N] `)) { lock = approvePackage(lock, id, digest); continue; }
1999
+ }
2000
+ approvalNeeded.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets });
2319
2001
  }
2320
- }
2321
-
2322
- function reconcileInner(dir) {
2323
- const cfgFile = join(dir, "oats-config.yaml");
2324
- const declaresTeamHere = existsSync(cfgFile) && !!withConfigFile(cfgFile, () => parseYamlNested(readFileSync(cfgFile, "utf8"))).team;
2325
- const recursive = args.includes("--recursive");
2326
- if (!declaresTeamHere && !recursive) {
2327
- // Current-chain behavior, plus the requirements gate for this chain's active capabilities.
2328
- const { report, failed } = restore(dir);
2329
- const requirements = requirementsGate([dir]);
2330
- const failures = [
2331
- // "legacy" is informational (v1 locks restore via the capability path);
2332
- // every other unsuccessful status is a failure (incl. retired/unrestorable
2333
- // per reviewer-6f0a3bd — they must not report ok).
2334
- ...report.filter((r) => Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status)).map((r) => ({ scope: r.level, id: r.package ? `package ${r.id}` : r.id, reason: r.reason, code: r.code || UNSUCCESSFUL_RESTORE[r.status] })),
2335
- ...requirements.filter((q) => q.outcome === "failed").map((q) => ({ scope: dir, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" })),
2336
- ];
2337
- void failed;
2338
- emitReconcileResult({
2339
- boundary: dir, boundaryKind: "chain",
2340
- scopes: [{ scope: dir, artifacts: report.map(artifactJson) }],
2341
- requirements, failures,
2342
- });
2343
- return;
2344
- }
2345
- const boundary = dir;
2346
- const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2347
- // The chosen boundary is printed BEFORE any network or host work — always.
2348
- note(`Workspace reconciliation boundary: ${shortPath(boundary)}${declaresTeamHere ? " (team scope)" : " (--recursive)"}`);
2349
- const scopes = [boundary, ...discoverWorkspaceScopes(boundary)];
2350
- const failures = [];
2351
- const scopeReports = [];
2352
- let reportedAny = false;
2353
- const restoredLevels = new Set(); // each lock level's graph restores exactly once
2354
- const packageCheckedLevels = new Set(); // each level's package-lock rows consumed exactly once
2355
- // reviewer-455ba15 fix 1 — partition-not-rerun: run the engine's chain-walking
2356
- // package restore as FEW times as the API allows and hand out each level's
2357
- // rows exactly once. One invocation covers a scope's entire ancestor chain;
2358
- // rows are stashed so no level is ever REPORTED twice and no already-walked
2359
- // level triggers a re-invocation. RESIDUAL (pending WS1's exact-levels API,
2360
- // relayed as a want): a descendant owning its own lock necessarily re-walks
2361
- // its ancestors inside the engine — present/valid ancestor artifacts re-verify
2362
- // with local reads only, but a FAILED ancestor fetch may retry once per
2363
- // lock-owning descendant. The exact-once reporting contract holds.
2364
- const pendingPkgRows = new Map(); // level(resolved) → rows not yet consumed
2365
- const packageRowsFor = (scope, levels) => {
2366
- const wanted = levels.map((l) => resolve(l)).filter((l) => !packageCheckedLevels.has(l));
2367
- if (!wanted.length) return [];
2368
- if (wanted.some((l) => !pendingPkgRows.has(l))) {
2369
- // One restore invocation covers scope's whole chain; stash every level's
2370
- // rows so later scopes never re-invoke for already-walked levels.
2371
- for (const [lvl, rows] of partitionedPackageRestore(scope)) {
2372
- if (!pendingPkgRows.has(lvl)) pendingPkgRows.set(lvl, rows);
2373
- }
2374
- }
2375
- const out = [];
2376
- for (const l of wanted) {
2377
- packageCheckedLevels.add(l);
2378
- out.push(...(pendingPkgRows.get(l) || []));
2379
- }
2380
- return out;
2381
- };
2382
- for (const scope of scopes) {
2383
- // Boundary: full ancestor chain (current-chain semantics). Descendants: their
2384
- // own level only — every level between boundary and descendant is either the
2385
- // boundary chain or an earlier discovered scope, so no level repeats and no
2386
- // failed ancestor restore is retried (or hidden) per descendant.
2387
- const chainLevels = scope === boundary ? undefined : [scope];
2388
- const report = restoreCapabilities(scope, chainLevels ? { levels: chainLevels.filter((l) => !restoredLevels.has(resolve(l))) } : undefined)
2389
- .filter((r) => !restoredLevels.has(resolve(r.level)));
2390
- // v2 package locks: every lock level this scope covers (the boundary covers
2391
- // its whole ancestor chain), each restored/verified exactly once.
2392
- report.push(...packageRowsFor(scope, scope === boundary ? lockLevelsUp(boundary) : [scope]));
2393
- for (const r of report) {
2394
- reportedAny = true;
2395
- const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
2396
- if (r.status === "present") note(`ok ${what} [${shortPath(r.level)}]`);
2397
- else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)} [${shortPath(r.level)}]`);
2398
- else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
2399
- else if (r.status === "retired") { failures.push({ scope: r.level, id: what, reason: r.reason, code: "retired-capability" }); note(`RETIRED ${what} ${r.reason} [${shortPath(r.level)}]`); }
2400
- else { failures.push({ scope: r.level, id: what, reason: r.reason, code: r.code }); note(`FAILED ${what} ${r.reason} [${shortPath(r.level)}]`); }
2401
- }
2402
- if (scope === boundary) for (const cfg of configChain(boundary)) restoredLevels.add(resolve(cfg._level));
2403
- for (const r of report) restoredLevels.add(resolve(r.level));
2404
- restoredLevels.add(resolve(scope));
2405
- // Validate: every config-referenced installed capability supplied by a visible locked package/capability lock.
2406
- if (existsSync(join(scope, "oats-config.yaml"))) {
2407
- try {
2408
- const supplied = lockedPackageCapabilities(scope);
2409
- const capLocks = readCapabilityLocks(scope);
2410
- for (const cfg of configChain(scope)) {
2411
- if (resolve(cfg._level) !== resolve(scope)) continue;
2412
- for (const [slot, entry] of Object.entries(cfg.capabilities?.layers || {})) {
2413
- if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(entry.capability) && !capLocks[entry.capability]) {
2414
- failures.push({ scope, id: entry.capability, reason: `referenced by capabilities.layers.${slot} but supplied by no visible locked package` });
2415
- }
2416
- }
2417
- for (const [id, entry] of Object.entries(cfg.capabilities?.additive || {})) {
2418
- if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(id) && !capLocks[id]) {
2419
- failures.push({ scope, id, reason: "referenced in config but supplied by no visible locked package" });
2420
- }
2421
- }
2422
- }
2423
- } catch (e) { failures.push({ scope, id: "(config)", reason: e.message }); }
2424
- }
2425
- scopeReports.push({ scope, artifacts: report.map(artifactJson) });
2426
- }
2427
- if (!reportedAny && scopes.length === 1) note("Nothing to restore — no locked capabilities or packages found in the boundary.");
2428
- const requirements = requirementsGate(scopes);
2429
- for (const q of requirements) {
2430
- if (q.outcome === "failed") failures.push({ scope: boundary, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" });
2431
- }
2432
- emitReconcileResult({
2433
- boundary, boundaryKind: declaresTeamHere ? "team" : "recursive",
2434
- scopes: scopeReports, requirements, failures,
2435
- });
2436
- }
2437
-
2438
- /** Host-requirement consent gate. Requirements are considered only for capabilities
2439
- * activated somewhere in the reconciled scopes, deduplicated by command. Interactive
2440
- * runs prompt per requirement with the exact command/source/version and state scope;
2441
- * non-interactive runs NEVER install by default — automation names each accepted
2442
- * requirement via --accept-requirement <command>; --no-requirements skips entirely.
2443
- * Skipping leaves an actionable doctor warning (doctor recomputes missing commands).
2444
- * Returns structured entries with a stable outcome enum:
2445
- * "installed" consented install ran and the command verified on PATH
2446
- * "failed" consented install errored or PATH verification missed (→ reconcile failure)
2447
- * "consent-required" not explicitly accepted — nothing installed
2448
- * "skipped" --no-requirements, or no safe installer for this host
2449
- * JSON plan data equals the human prompt plan (argv/source/version/scope/requestedBy;
2450
- * never shell text). In JSON mode all prose goes to stderr. */
2451
- function requirementsGate(scopes) {
2452
- // Malformed repeatable flags are usage errors regardless of which branch
2453
- // runs — validate up front so --no-requirements cannot mask them.
2454
- const accepted = new Set(flagAll("accept-requirement"));
2455
- // Explicitly named requirements bypass runtime scoping, so the remediation
2456
- // command a failed spawn prints actually installs something.
2457
- const missing = aggregateMissingRequirements(scopes, { accepted });
2458
- if (!missing.length) return [];
2459
- const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2460
- const entryOf = (req, outcome, extra = {}) => ({
2461
- command: req.command, kind: req.kind || "host-command",
2462
- runtime: req.runtime || null, package: req.package || null, why: req.why || null,
2463
- // `steps` is the ORDERED sequence runRequirementInstall actually executes;
2464
- // `argv` is only its last command. Serializing argv alone hid a
2465
- // `claude plugin marketplace add <source>` — a lower-trust source
2466
- // registration — from every client consenting through the JSON API
2467
- // (reviewer-final0130bc8). Always present, exactly as doctor renders it, so
2468
- // single- and multi-step plans have one shape.
2469
- plan: req.plan && !req.plan.unavailable
2470
- ? { manager: req.plan.manager, argv: req.plan.argv, steps: req.plan.steps || [req.plan.argv], source: req.plan.source, version: req.plan.version || null, scope: req.plan.scope }
2471
- : null,
2472
- requestedBy: req.requestedBy, docs: req.docs || null, outcome, ...extra,
2473
- });
2474
- // Fail-closed identity/conflict policy (E_REQUIREMENT_POLICY): invalid command
2475
- // tokens and same-command conflicting plans are NEVER consentable or installable
2476
- // — they fail reconciliation deterministically with provenance, even under
2477
- // --no-requirements (skipping consent does not skip safety validation).
2478
- const policyEntries = [];
2479
- for (const req of missing) {
2480
- if (req.invalid) {
2481
- note(` INVALID requirement command ${JSON.stringify(req.command)} — ${req.invalid} (requested by: ${req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ")})`);
2482
- policyEntries.push(entryOf(req, "failed", { reason: req.invalid, code: "E_REQUIREMENT_POLICY" }));
2483
- } else if (req.conflict) {
2484
- note(` CONFLICT for command "${req.command}": capabilities request non-identical install plans — no install is offered`);
2485
- // Show the FULL sequence: two capabilities can agree on the final install
2486
- // command while registering different third-party sources before it.
2487
- for (const p of req.conflict.plans) {
2488
- const shown = p.steps?.length ? p.steps.map((a) => a.join(" ")).join(" && ") : (p.argv ? p.argv.join(" ") : p.unavailable || "no plan");
2489
- note(` ${p.capability} [${shortPath(p.scope)}]: ${shown}`);
2490
- }
2491
- policyEntries.push(entryOf(req, "failed", { reason: "conflicting install plans for the same command", code: "E_REQUIREMENT_POLICY", conflict: req.conflict }));
2492
- }
2493
- }
2494
- const consentable = missing.filter((req) => !req.invalid && !req.conflict);
2495
- if (args.includes("--no-requirements")) return [...policyEntries, ...consentable.map((req) => entryOf(req, "skipped", { reason: "--no-requirements" }))];
2496
- const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
2497
- const out = [...policyEntries];
2498
- if (consentable.length) note(`\nMissing requirements for active capabilities (${consentable.length}):`);
2499
- for (const req of consentable) {
2500
- const requesters = req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ");
2501
- note(` ${req.command} — ${req.why || "required"} (requested by: ${requesters})`);
2502
- const plan = req.plan;
2503
- if (!plan || plan.unavailable) {
2504
- note(` no safe installer: ${plan?.unavailable || "no recipe"}${req.docs ? ` — install docs: ${req.docs}` : ""}`);
2505
- out.push(entryOf(req, "skipped", { reason: plan?.unavailable || "no safe installer" }));
2506
- continue;
2507
- }
2508
- // Show EVERY step: installing a Claude plugin also registers a third-party
2509
- // marketplace, and consent to that must be visible, not implied.
2510
- const shown = (plan.steps?.length ? plan.steps : [plan.argv]).map((a) => a.join(" ")).join(" && ");
2511
- note(` installer: ${shown} (source: ${plan.source}${plan.version ? `, version ${plan.version}` : ""}; ${plan.scope})`);
2512
- let consent = accepted.has(req.command);
2513
- if (!consent && interactive) {
2514
- process.stdout.write(` Run this install now? [y/N] `);
2515
- const buf = Buffer.alloc(64);
2516
- let answer = "";
2517
- try { answer = buf.toString("utf8", 0, readSync(process.stdin.fd, buf, 0, 64)).trim().toLowerCase(); } catch { /* EOF */ }
2518
- consent = answer === "y" || answer === "yes";
2519
- }
2520
- if (!consent) {
2521
- note(` skipped — ${interactive ? "not consented" : "non-interactive; pass --accept-requirement " + req.command + " to install"}; \`oats doctor\` will keep warning until ${req.command} is ${req.kind === "runtime-package" ? `installed for ${req.runtime}` : "on PATH"}`);
2522
- out.push(entryOf(req, "consent-required"));
2523
- continue;
2524
- }
2525
- try {
2526
- const r = runRequirementInstall(plan, JSON_MODE ? { stdio: ["ignore", 2, 2] } : {});
2527
- // A runtime package is verified in its runtime's package list, never on
2528
- // PATH — saying "on PATH" for one would be false either way it lands.
2529
- const where = req.kind === "runtime-package" ? `installed for ${req.runtime}` : "on PATH";
2530
- if (r.onPath) { note(` installed — ${req.command} verified ${where}`); out.push(entryOf(req, "installed", { onPath: true })); }
2531
- else { note(` FAILED: install ran but ${req.command} is still not ${where}${req.kind === "runtime-package" ? "" : " — check your shell PATH/prefix"}`); out.push(entryOf(req, "failed", { onPath: false, reason: `install ran but the requirement is still not ${where}` })); }
2532
- } catch (e) {
2533
- note(` FAILED: ${e.message}`);
2534
- out.push(entryOf(req, "failed", { onPath: false, reason: e.message }));
2535
- }
2536
- }
2537
- note("Requirement consent is separate from capability trust — installing a binary does not activate or approve any capability.");
2538
- return out;
2539
- }
2540
-
2541
- /** oats trust <capability> | oats trust <package> --all-capabilities */
2542
- function trust() {
2543
- const id = args[1];
2544
- if (!id || id.startsWith("--")) { cmdFail("E_USAGE", "usage: oats trust <capability> [--dir <dir>] | oats trust <package> --all-capabilities [--dir <dir>]"); return; }
2545
- const dir = dirFlag();
2546
- const all = args.includes("--all-capabilities");
2547
- // A v3 deployment needs an EXPLICIT immutable artifact-set selection. Never
2548
- // guess one or reinterpret its lock through the classic approval engine.
2549
- try {
2550
- for (const scope of lockLevelsUp(dir).reverse()) {
2551
- // Discriminate with the shared bounded decoder only. Classic readers
2552
- // retain all v1/v2 validation, including implicit (versionless) v1 locks.
2553
- let version;
2554
- try { version = parseStrictJson(readPortableBytes(join(scope, OATS_LOCK_FILE)))?.lockfileVersion; }
2555
- catch { continue; } // Let the existing classic reader diagnose its input.
2556
- if (version !== 3) continue;
2557
- const portable = readLock3(scope).lock;
2558
- if (!portable) throw oatsError("selection-changed", "selection lock disappeared; repeat explicit trust selection");
2559
- const sets = [...new Set(Object.values(portable.selections).map(row => row.available).filter(key => key && Object.hasOwn(portable.artifactSets[key].capabilities, id)))].sort();
2560
- const commands = sets.map(artifactSet => ({ artifactSet,
2561
- command: `oats trust ${shellQuote(id)} --deployment ${shellQuote(scope)} --artifact-set ${shellQuote(artifactSet)}` }));
2562
- const message = commands.length && !all
2563
- ? `lock v3 requires exact artifact-set approval; run ${commands.map(item => item.command).join(" or ")}`
2564
- : `lock v3 approvals are per capability; use prepare's selections[].artifactSet for each capability in approvalRequired[]: oats trust <capability> --deployment ${shellQuote(scope)} --artifact-set <sha256-artifact-set>`;
2565
- if (JSON_MODE) jsonFail("needs-configuration", message, { deployment: scope, commands });
2566
- die(message);
2567
- }
2568
- } catch (error) { cmdFail(error.code || "invalid-lock", error.message); return; }
2569
- // Package-backed approval path (per-capability, or explicit bulk on a package id).
2570
- let pkgs, locks;
2571
- try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
2572
- // findLast: the listing runs outermost → innermost, and an identity resolves
2573
- // to the CLOSEST scope that locks it — the same rule the merged lock maps use.
2574
- const backing = all ? pkgs.findLast((p) => p.package === id) : pkgs.findLast((p) => p.capabilities.some((c) => c.id === id));
2575
- if (backing) {
2576
- // Approval is per capability unless --all-capabilities is explicit. Print
2577
- // exactly the authority this invocation will persist, before persisting it;
2578
- // JSON mode uses stderr so stdout remains one machine envelope.
2579
- const requested = all ? backing.capabilities : backing.capabilities.filter((c) => c.id === id);
2580
- const out = JSON_MODE ? console.error : console.log;
2581
- out(`Package ${backing.package}@${backing.version} ${all ? "full" : "requested"} executable surface:`);
2582
- for (const c of requested) {
2583
- const cmds = Object.keys(c.manifest.commands || {});
2584
- const hooks = Object.keys(c.manifest.hooks || {});
2585
- const environment = c.manifest.environment || [];
2586
- out(` ${c.id}: commands [${cmds.join(", ") || "none"}], hooks [${hooks.join(", ") || "none"}], launch environment [${environment.join(", ") || "none"}]`);
2587
- }
2588
- // FAIL CLOSED BEFORE APPROVING. The engine binds approval to the artifact's
2589
- // integrity, but integrity alone cannot see a `.oats-installation.json` that
2590
- // claims a different origin than the lock — and approving a capability whose
2591
- // own provenance is disputed is exactly the thing trust must not do.
2592
- const trustRows = levelRows(locks, backing.level);
2593
- const disputed = backing.capabilities
2594
- .filter((c) => all || c.id === id)
2595
- .map((c) => capabilityHealth(backing.level, c, trustRows.capabilities[c.id], trustRows.packages[backing.package]))
2596
- .filter((h) => h.status !== "ok" && h.status !== "untrusted");
2597
- if (disputed.length) { cmdFail(disputed[0].code || "invalid-lock", `refusing to trust: ${disputed.map((h) => h.detail).join("; ")}`); return; }
2598
- let r;
2599
- try { r = approveCapability(dir, id, { allCapabilities: all }); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
2600
- // Approval binds to each capability's exact MATERIALIZED ARTIFACT, so the
2601
- // integrity reported is per capability — there is no package-level digest
2602
- // to approve against and none to print.
2603
- const approvedIntegrity = {};
2604
- for (const c of backing.capabilities) if (r.approved.includes(c.id)) approvedIntegrity[c.id] = c.integrity || null;
2605
- if (JSON_MODE) {
2606
- // The engine's own surface, not a re-derivation: what it approved and what
2607
- // it saw must be the same object.
2608
- jsonOk({ package: r.package, level: r.level, approved: r.approved, skipped: r.skipped, approvedIntegrity, executableSurface: r.executableSurface, file: r.file });
2609
- return;
2610
- }
2611
- for (const c of r.approved) console.log(`Trusted executable surface for ${c} (from package ${r.package}, artifact ${approvedIntegrity[c] || "?"}).`);
2612
- if (r.skipped.length) console.log(`No executable surface (artifact integrity suffices, no approval needed): ${r.skipped.join(", ")}`);
2613
- return;
2614
- }
2615
- if (all) { cmdFail("unknown-capability", `no installed package "${id}" — --all-capabilities takes a package identity`); return; }
2616
- // Legacy standalone capability path.
2617
- const manifest = capabilityManifest(id, dir);
2618
- if (!manifest) { cmdFail("unknown-capability", `unknown capability "${id}"`); return; }
2619
- const lock = readCapabilityLocks(dir)[manifest.capability];
2620
- if (!lock) { cmdFail("invalid-lock", `${manifest.capability} is not locked in ${OATS_LOCK_FILE}`); return; }
2621
- const integrity = capabilityIntegrity(manifest._dir);
2622
- if (integrity !== lock.integrity) { cmdFail("integrity-drift", `integrity changed (${lock.integrity} → ${integrity}); reacquire explicitly before trusting`); return; }
2623
- const { _file, ...clean } = lock;
2624
- if (manifest.environment?.length) (JSON_MODE ? console.error : console.log)(`Requested launch environment: ${manifest.environment.join(", ")}`);
2625
- try { writeCapabilityLock(dirname(_file), manifest.capability, { ...clean, trustedExecutables: true }); }
2626
- catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
2627
- if (JSON_MODE) { jsonOk({ capability: manifest.capability, integrity, legacy: true, environment: [...(manifest.environment || [])] }); return; }
2628
- console.log(`Trusted executable surface for ${manifest.capability} at ${integrity}.`);
2629
- }
2630
-
2631
- // ---------- package config profiles (oats init --package / oats config diff) ----------
2632
- /** Collect every value of a repeatable flag (e.g. --accept-requirement a --accept-requirement b).
2633
- * A missing or flag-shaped value is a usage error — one E_USAGE envelope in JSON mode. */
2634
- function flagAll(name) {
2635
- const out = [];
2636
- for (let i = 0; i < args.length; i++) {
2637
- if (args[i] !== `--${name}`) continue;
2638
- if (args[i + 1] && !args[i + 1].startsWith("--")) out.push(args[i + 1]);
2639
- else (JSON_MODE ? jsonFail("E_USAGE", `--${name} needs a value`) : die(`--${name} needs a value`));
2640
- }
2641
- return out;
2642
- }
2643
-
2644
- /** Dependency-closure PROVIDER RECORDS for config-template validation.
2645
- *
2646
- * The flat model made this much smaller than its package-root ancestor: the
2647
- * engine's lock reader walks raw lock-owning scopes rather than the config
2648
- * chain, so a configless scope being initialized now sees its OWN lock without
2649
- * the manual merge this used to need, and capability rows carry the provider
2650
- * back-reference directly instead of package rows carrying capability lists.
2651
- *
2652
- * `staged` supplies the capabilities projected by THIS run's acquisition, which
2653
- * are not locked yet when the pre-commit gate validates the template.
2654
- * Returns { capabilities: Map<capabilityId, capabilityManifest|null> } — null
2655
- * means lock-visible but not materialized, so layer agreement is unverifiable.
2656
- */
2657
- function dependencyClosureProviders(rootId, dir, staged = []) {
2658
- const capabilities = new Map();
2659
- let locks = { packages: {}, capabilities: {} };
2660
- try { locks = readPackageLocks(dir); } catch { /* invalid lock surfaces at acquire */ }
2661
-
2662
- const closure = new Set();
2663
- const visit = (pkgId) => {
2664
- if (!pkgId || closure.has(pkgId) || !Object.hasOwn(locks.packages, pkgId)) return;
2665
- closure.add(pkgId);
2666
- for (const dep of locks.packages[pkgId].dependencies || []) visit(dep);
2667
- };
2668
- visit(rootId);
2669
-
2670
- for (const [capId, row] of Object.entries(locks.capabilities)) {
2671
- if (!closure.has(row.package)) continue;
2672
- let manifest = null;
2673
- try {
2674
- const artifact = installedCapabilityDir(row._level, capId);
2675
- if (existsSync(join(artifact, "oats.json"))) manifest = JSON.parse(readFileSync(join(artifact, "oats.json"), "utf8"));
2676
- } catch { /* unreadable artifact is a doctor problem, not a validation input */ }
2677
- capabilities.set(capId, manifest);
2678
- }
2679
- // Same-run acquisition visibility: the root's own exports exist only in
2680
- // staging while the gate runs, and a template that binds them must validate.
2681
- // Preview rows carry the declared `layer` (null when none) — the minimum the
2682
- // layer-agreement check needs — so a staged capability is represented by that
2683
- // one field rather than a manifest the engine deliberately does not expose.
2684
- for (const c of staged) capabilities.set(c.capability, c.manifest ?? { layer: c.layer ?? null });
2685
- return { capabilities };
2686
- }
2687
-
2688
- /** Adopt a template from a package ALREADY locked at this scope: read its exact
2689
- * locked templates, validate, then write config + base + metadata under the run
2690
- * journal. Nothing is fetched beyond the locked source, and nothing is
2691
- * re-acquired — the lock is already the truth about what is installed here. */
2692
- function initPackageFromLock(packageId, dir, file, lockedRoot, configFlag, bail, note) {
2693
- let locked, chosen;
2694
- try { locked = readLockedConfigTemplates(dir, packageId); }
2695
- catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); return; }
2696
- try { chosen = selectConfigTemplate(locked.templates, configFlag, packageId); }
2697
- catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); return; }
2698
-
2699
- const errors = validateConfigTemplate(chosen, packageId, {
2700
- dependencyProviders: dependencyClosureProviders(packageId, dir).capabilities,
2701
- });
2702
- if (errors.length) bail("E_TEMPLATE_INVALID", `config template "${chosen.template}" of package ${packageId} failed validation:\n - ${errors.join("\n - ")}`);
2703
-
2704
- note(`Package ${packageId}${locked.version ? `@${locked.version}` : ""} is already locked here — adopting its config template "${chosen.template}" without re-acquiring.`);
2705
-
2706
- let journal;
2707
- try { journal = beginRunJournal(dir); }
2708
- catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
2709
- let adoption;
2710
- try {
2711
- adoption = writeAdoptedTemplate(dir, file, {
2712
- package: packageId, template: chosen,
2713
- root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
2714
- });
2715
- journal.finalize();
2716
- } catch (e) {
2717
- const report = journal.rollback();
2718
- bail("E_ADOPT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
2719
- return;
2720
- }
2721
-
2722
- const locks = readPackageLocks(dir);
2723
- const capabilities = Object.entries(locks.capabilities).filter(([, c]) => c.package === packageId).map(([id]) => id);
2724
- note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${packageId}:${chosen.template}`);
2725
- if (JSON_MODE) {
2726
- jsonOk({
2727
- package: packageId, version: locked.version || null, commit: locked.commit || null,
2728
- template: chosen.template, adopted: true, file, capabilities,
2729
- adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
2730
- contentIntegrity: chosen.contentIntegrity,
2731
- lockFile: lockedRoot._file || join(dir, OATS_LOCK_FILE), lockedPackages: Object.keys(locks.packages),
2732
- });
2733
- return;
2734
- }
2735
- offerTmuxMouseScrolling();
2736
- }
2737
-
2738
- /** oats init --package <source> [--config <name>]: acquire a package AND adopt
2739
- * one of its config templates as this scope's local config.
2740
- *
2741
- * This command is adoption, not an install alias: `oats install <package>`
2742
- * installs capabilities and applies no template, while this one always adopts
2743
- * exactly one — the named template, else the single marked default, else the
2744
- * only one. Several unmarked templates are E_TEMPLATE_AMBIGUOUS and a package
2745
- * with none is E_NO_TEMPLATES; both refuse inside the pre-commit gate, so the
2746
- * scope is never touched.
2747
- *
2748
- * Transaction shape: the outer journal opens BEFORE acquisition, so its
2749
- * snapshot is the pre-command state. A gate refusal or acquire failure rolls it
2750
- * back (the engine is zero-mutation there, so this mainly closes the backup); a
2751
- * failure while writing the adoption files rolls back the lock, the capability
2752
- * store, the ignore file, the config and the adopted base together — the
2753
- * newly acquired state disappears and every pre-existing byte returns.
2754
- * finalize() runs only after every adoption write has succeeded.
2755
- *
2756
- * JSON mode: one compact envelope. CLI codes E_TEMPLATE_INVALID /
2757
- * E_TEMPLATE_AMBIGUOUS / E_TEMPLATE_NOT_FOUND / E_NO_TEMPLATES; engine codes
2758
- * pass through verbatim. Fully noninteractive. */
2759
- function initPackage(src, dir, file) {
2760
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2761
- const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2762
- const configFlag = flag("config");
2763
- if (configFlag === true) bail("E_USAGE", "--config needs a template name");
2764
-
2765
- let chosen = null; // the selected+validated template descriptor
2766
- let rootRecord = null; // the acquired root package row
2767
- let projected = []; // projected capability rows
2768
-
2769
- /** The pre-commit gate. Everything that can refuse refuses HERE, while the
2770
- * scope is still untouched. It THROWS rather than exiting: the process-exit
2771
- * path would strand the journal's backup, and the engine propagates a gate
2772
- * throw unchanged with nothing mutated. */
2773
- const assertCommittable = (preview) => {
2774
- rootRecord = preview.packages.find((p) => p.package === preview.root) || null;
2775
- projected = preview.capabilities || [];
2776
- const templates = (preview.configTemplates || []).filter((t) => t.package === preview.root);
2777
-
2778
- note(`Package ${preview.root}${rootRecord?.version ? `@${rootRecord.version}` : ""} — installs ${projected.length} capability(ies): ${projected.map((c) => c.capability).join(", ") || "(none)"}`);
2779
- const executable = projected.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length);
2780
- if (executable.length) note(` executable surfaces needing separate approval: ${executable.map((c) => c.capability).join(", ")} (\`oats trust <id>\`)`);
2781
-
2782
- chosen = selectConfigTemplate(templates, configFlag, preview.root); // throws typed codes
2783
- // Every check now refuses PRE-COMMIT, layer agreement included: preview
2784
- // capability rows carry the declared layer, so a template binding a slot to
2785
- // one of the package's own staged capabilities is validated here, with the
2786
- // scope untouched and no rollback needed.
2787
- const errors = validateConfigTemplate(chosen, preview.root, {
2788
- dependencyProviders: dependencyClosureProviders(preview.root, dir, projected).capabilities,
2789
- });
2790
- if (errors.length) {
2791
- const e = new Error(`config template "${chosen.template}" of package ${preview.root} failed validation:\n - ${errors.join("\n - ")}`);
2792
- e.code = "E_TEMPLATE_INVALID";
2793
- throw e;
2794
- }
2795
- note(`Config template "${chosen.template}"${chosen.description ? `: ${chosen.description}` : ""} — validated (${chosen.contentIntegrity})`);
2796
- note(` it becomes YOUR local ${shortPath(file)}: every copied setting is editable, and package updates never rewrite it.`);
2797
- };
2798
-
2799
- // Opened BEFORE acquisition: a snapshot taken afterwards would record the new
2800
- // lock, artifacts and ignore bytes as the "pre-existing" state and could
2801
- // never undo them.
2802
- // An id already locked at this scope is adopted from the LOCK, not
2803
- // re-acquired: its exact source/commit is already pinned, the capabilities are
2804
- // already materialized, and going to the network (or the catalog) to re-fetch
2805
- // what the lock already names would be a different package than the one
2806
- // installed here. This is the `oats init --package <id>` half of the documented
2807
- // <id|path|git-url> form.
2808
- let lockedRoot = null;
2809
- try { lockedRoot = readPackageLocks(dir).packages[src] || null; }
2810
- catch { /* an invalid lock surfaces with its own typed code below */ }
2811
- if (lockedRoot) { initPackageFromLock(src, dir, file, lockedRoot, configFlag, bail, note); return; }
2812
-
2813
- // Constructed inside its own guard: a journal that cannot be built (a symlink
2814
- // component, an unreadable snapshot, a backup that would land inside the
2815
- // scope) must still leave the command with exactly one JSON envelope. There
2816
- // is nothing to roll back yet, so its typed code goes straight to bail.
2817
- let journal;
2818
- try { journal = beginRunJournal(dir); }
2819
- catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
2820
-
2821
- /** Undo the run, then report. `code` is the engine's verbatim code for
2822
- * acquisition failures and a stable CLI code for our own write failures — a
2823
- * raw errno like ENOTDIR is not a contract automation can branch on. */
2824
- const abort = (e, code) => {
2825
- const report = journal.rollback();
2826
- const detail = code === "E_ADOPT_FAILED" ? `adopting the config template failed after the package was installed: ${e.message}` : e.message;
2827
- bail(code || e.code || "E_INIT_FAILED", report.complete ? detail : `${detail} — ${report.summary}`);
2828
- };
2829
-
2830
- let acq;
2831
- try { acq = acquirePackage(dir, src, { assertCommittable }); }
2832
- catch (e) { abort(e); return; }
2833
-
2834
- note(`Acquired + locked: ${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")} → ${shortPath(acq.lockFile)}`);
2835
- const capabilities = acq.capabilities.map((c) => c.capability);
2836
-
2837
- // DEFENCE IN DEPTH, not the primary check. The gate above already validated
2838
- // every binding against the preview's declared layers; this re-checks them
2839
- // against the manifests actually written to disk, so a projection that
2840
- // disagreed with its own preview cannot leave a broken config behind. It
2841
- // should never fire — and if it does, the journal restores the scope
2842
- // completely, so nothing of the run survives.
2843
- const materialized = new Map();
2844
- for (const c of acq.capabilities) {
2845
- let manifest = null;
2846
- try { manifest = JSON.parse(readFileSync(join(installedCapabilityDir(dir, c.capability), "oats.json"), "utf8")); }
2847
- catch { /* unreadable artifact is reported by doctor; leave it unverifiable */ }
2848
- materialized.set(c.capability, manifest);
2849
- }
2850
- for (const [id, m] of dependencyClosureProviders(acq.root, dir).capabilities) if (!materialized.has(id)) materialized.set(id, m);
2851
- const lateErrors = validateConfigTemplate(chosen, acq.root, { dependencyProviders: materialized });
2852
- if (lateErrors.length) {
2853
- const e = new Error(`config template "${chosen.template}" of package ${acq.root} failed validation:\n - ${lateErrors.join("\n - ")}`);
2854
- e.code = "E_TEMPLATE_INVALID";
2855
- abort(e);
2856
- return;
2857
- }
2858
-
2859
- let adoption;
2860
- try {
2861
- adoption = writeAdoptedTemplate(dir, file, { package: acq.root, template: chosen, root: rootRecord });
2862
- journal.finalize();
2863
- } catch (e) { abort(e, "E_ADOPT_FAILED"); return; }
2864
-
2865
- note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${acq.root}:${chosen.template}`);
2866
- note(`Recorded the adopted base at ${shortPath(adoption.baseFile)} — commit it; \`oats config diff\` and \`oats config sync\` compare against it.`);
2867
- if (JSON_MODE) {
2868
- jsonOk({
2869
- package: acq.root, version: rootRecord?.version || null, commit: rootRecord?.commit || null,
2870
- template: chosen.template, adopted: true, file, capabilities,
2871
- adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
2872
- contentIntegrity: chosen.contentIntegrity,
2873
- lockFile: acq.lockFile, lockedPackages: acq.installed.map((p) => p.package),
2874
- });
2875
- return;
2876
- }
2877
- offerTmuxMouseScrolling();
2878
- }
2879
-
2880
- /** `oats config <diff|sync|adopt>` — the guided three-way template lane.
2881
- *
2882
- * All three share one comparison: the recorded adopted base, the current local
2883
- * oats-config.yaml, and the selected template read from the CURRENT EXACT LOCK.
2884
- * Only `sync` and `adopt` mutate, and both present the complete plan first.
2885
- */
2886
- function configCmd() {
2887
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2888
- const sub = args[1];
2889
- if (!["diff", "sync", "adopt"].includes(sub)) {
2890
- bail("E_USAGE", "usage: oats config <diff|sync|adopt> [--config <template>] [--dir <dir>] [--json]");
2891
- }
2892
- const dir = resolve(flag("dir") || process.cwd());
2893
- const file = join(dir, "oats-config.yaml");
2894
- if (!existsSync(file)) bail("E_NO_CONFIG", `no oats-config.yaml at ${shortPath(dir)} — adopt one with \`oats init --package <source> --config <name>\``);
2895
- const localText = readFileSync(file, "utf8");
2896
-
2897
- let adopted;
2898
- try { adopted = readAdoptedTemplate(dir); }
2899
- catch (e) { bail(e.code || "E_ADOPTION_INVALID", e.message); }
2900
-
2901
- // `adopt` switches base; the others need an existing one.
2902
- const adoptTarget = sub === "adopt" ? args[2] : undefined;
2903
- if (sub === "adopt" && (!adoptTarget || adoptTarget.startsWith("--"))) {
2904
- bail("E_USAGE", "usage: oats config adopt <package> [--config <template>] — the package must already be installed at this scope");
2905
- }
2906
- if (sub !== "adopt" && !adopted) {
2907
- bail("E_NO_ADOPTED_BASE", `${shortPath(file)} was not adopted from a config template, so there is no recorded base to compare against — adopt one with \`oats config adopt <package> --config <name>\``);
2908
- }
2909
-
2910
- const packageId = sub === "adopt" ? adoptTarget : adopted.package;
2911
- const templateFlag = flag("config");
2912
- if (templateFlag === true) bail("E_USAGE", "--config needs a template name");
2913
- const wanted = templateFlag || (sub === "adopt" ? undefined : adopted.template);
2914
-
2915
- // Exact locked read — never the network-free guess, never a package root.
2916
- let locked;
2917
- try { locked = readLockedConfigTemplates(dir, packageId); }
2918
- catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); }
2919
- let chosen;
2920
- try { chosen = selectConfigTemplate(locked.templates, wanted, packageId); }
2921
- catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); }
2922
-
2923
- // Switching base rebases the ONE local config against the new template.
2924
- //
2925
- // With no adopted base there is NO common ancestor, and pretending the local
2926
- // file is one is the dangerous answer: a three-way merge whose base equals
2927
- // local classifies every difference as upstream-only, so a first adopt would
2928
- // silently replace a handcrafted config wholesale — no conflicts, no consent,
2929
- // no preview of what was lost. An EMPTY base states the truth instead: every
2930
- // existing local byte is local work, and anything the template also wants to
2931
- // put there is a conflict the operator must resolve explicitly.
2932
- const baseText = adopted ? adopted.baseText : "";
2933
- const plan = planConfigMerge(baseText, localText, chosen.content);
2934
-
2935
- const describe = (r) => ({
2936
- id: r.id, kind: r.kind, recommended: r.recommended, digest: r.digest,
2937
- startLine: r.local.start + 1, lines: r.local.end - r.local.start,
2938
- base: r.base.text, local: r.local.text, package: r.template.text,
2939
- });
2940
-
2941
- if (sub === "diff") {
2942
- if (JSON_MODE) {
2943
- jsonOk({
2944
- package: packageId, template: chosen.template, version: locked.version || null, commit: locked.commit || null,
2945
- file, adoptedBase: adopted?.baseFile || null, contentIntegrity: chosen.contentIntegrity,
2946
- clean: plan.clean, counts: plan.counts, conflicts: plan.conflicts,
2947
- regions: plan.regions.map(describe), planDigest: plan.planDigest,
2948
- });
2949
- return;
2950
- }
2951
- console.log(`oats config diff — ${shortPath(file)} vs ${packageId}:${chosen.template}${locked.version ? `@${locked.version}` : ""} (report only; nothing is written)\n`);
2952
- if (!plan.regions.length) { console.log("No differences: your config, the adopted base, and the package template agree."); return; }
2953
- for (const r of plan.regions) renderMergeRegion(r);
2954
- console.log(`\n${plan.counts.upstream} upstream-only, ${plan.counts.local} local-only, ${plan.counts.conflict} conflict(s), ${plan.counts.agreed} already agreed.`);
2955
- console.log(plan.clean
2956
- ? "Apply the upstream changes with `oats config sync` (local-only edits are kept)."
2957
- : "`oats config sync` needs an explicit choice for each conflict — it will never pick one for you.");
2958
- return;
2959
- }
2960
-
2961
- // ---- sync / reset / adopt: everything below MUTATES, so plan first ----
2962
-
2963
- const decisions = {};
2964
- for (const spec of flagAll("accept")) {
2965
- const m = /^([^=]+)=(local|package)$/.exec(spec);
2966
- if (!m) bail("E_USAGE", `--accept takes <regionId>=<local|package>, got "${spec}"`);
2967
- decisions[m[1]] = m[2];
2968
- }
2969
- const assumeYes = args.includes("--yes");
2970
- const isReset = args.includes("--reset");
2971
-
2972
- // The recoverable backup survives a SUCCESSFUL run: the run journal is for
2973
- // undoing failures, this is for the adopter who changes their mind.
2974
- const backupFile = `${file}.bak`;
2975
-
2976
- if (isReset) {
2977
- // Reset previews everything it will destroy, then demands explicit consent.
2978
- const lost = plan.regions.filter((r) => r.kind === "local" || r.kind === "conflict");
2979
- if (JSON_MODE || !process.stdin.isTTY) {
2980
- if (!assumeYes) {
2981
- bail("E_RESET_NOT_CONFIRMED", `oats config sync --reset would discard ${lost.length} local change region(s) in ${shortPath(file)} and replace it with ${packageId}:${chosen.template} verbatim — pass --yes to accept that noninteractively`);
2982
- }
2983
- } else if (!assumeYes) {
2984
- console.log(`This DISCARDS ${lost.length} local change region(s) in ${shortPath(file)}:\n`);
2985
- for (const r of lost) renderMergeRegion(r);
2986
- const answer = promptLine(`Type the word "discard" to replace it with ${packageId}:${chosen.template}: `);
2987
- if (answer.trim() !== "discard") bail("E_RESET_NOT_CONFIRMED", "reset cancelled — nothing was changed");
2988
- }
2989
- const journal = openJournal(dir, bail);
2990
- try {
2991
- // NEVER copyFileSync onto a fixed backup path: it opens the destination
2992
- // for write and therefore FOLLOWS it, so a pre-planted
2993
- // `oats-config.yaml.bak` symlink would redirect this copy onto whatever it
2994
- // points at. The atomic form replaces the entry itself.
2995
- if (existsSync(file)) copyFileAtomic(file, backupFile);
2996
- writeFileAtomic(file, chosen.content);
2997
- recordAdoption(dir, file, packageId, chosen, locked, adopted);
2998
- journal.finalize();
2999
- } catch (e) { abortRun(journal, e, bail); return; }
3000
- if (JSON_MODE) { jsonOk({ action: "reset", package: packageId, template: chosen.template, file, backup: backupFile, discardedRegions: lost.length, contentIntegrity: chosen.contentIntegrity }); return; }
3001
- console.log(`Reset ${shortPath(file)} to ${packageId}:${chosen.template} verbatim. Previous contents saved at ${shortPath(backupFile)}.`);
3002
- return;
3003
- }
3004
-
3005
- // sync / adopt share the three-way apply.
3006
- const unresolved = plan.conflicts.filter((id) => !Object.hasOwn(decisions, id));
3007
- if (unresolved.length) {
3008
- if (JSON_MODE || !process.stdin.isTTY) {
3009
- bail("E_SYNC_AMBIGUOUS", `${unresolved.length} conflict(s) need an explicit choice (${unresolved.join(", ")}) — pass --accept <id>=<local|package> for each; this command will never choose for you`);
3010
- }
3011
- for (const id of unresolved) {
3012
- const region = plan.regions.find((r) => r.id === id);
3013
- renderMergeRegion(region);
3014
- const answer = promptLine(`[${id}] keep (l)ocal or take (p)ackage? `).trim().toLowerCase();
3015
- if (answer === "l" || answer === "local") decisions[id] = "local";
3016
- else if (answer === "p" || answer === "package") decisions[id] = "package";
3017
- else bail("E_SYNC_AMBIGUOUS", `no choice made for ${id} — nothing was changed`);
3018
- }
3019
- }
3020
-
3021
- let merged;
3022
- try { merged = applyConfigMerge(localText, plan, decisions); }
3023
- catch (e) { bail(e.code || "E_SYNC_FAILED", e.message); return; }
3024
-
3025
- // Advancing the recorded base is the POINT of a sync, not a side effect of
3026
- // changing bytes. Deciding "keep local" on every conflict changes nothing on
3027
- // disk, but the decision must still be recorded — otherwise the base stays
3028
- // behind and the identical conflict is re-presented on every future sync,
3029
- // forever. So "nothing to do" means nothing applied AND the base already at
3030
- // this exact template.
3031
- const baseIsCurrent = adopted?.package === packageId
3032
- && adopted?.template === chosen.template
3033
- && adopted?.baseText === chosen.content;
3034
- if (!merged.applied.length && baseIsCurrent) {
3035
- if (JSON_MODE) { jsonOk({ action: sub, package: packageId, template: chosen.template, file, changed: false, baseAdvanced: false, applied: [], backup: null }); return; }
3036
- console.log(`Nothing to do: ${shortPath(file)} and the recorded base are already at ${packageId}:${chosen.template}.`);
3037
- return;
3038
- }
3039
-
3040
- if (!JSON_MODE) {
3041
- console.log(`Plan for ${shortPath(file)} vs ${packageId}:${chosen.template}:`);
3042
- for (const a of merged.applied) console.log(` [${a.id}] ${a.kind} → ${a.choice}`);
3043
- console.log("");
3044
- }
3045
-
3046
- const changed = merged.text !== localText;
3047
- const journal = openJournal(dir, bail);
3048
- try {
3049
- // Back up only when bytes actually change — a backup identical to the file
3050
- // it shadows is noise the adopter has to reason about later.
3051
- if (changed) copyFileAtomic(file, backupFile);
3052
- if (changed) writeFileAtomic(file, merged.text);
3053
- recordAdoption(dir, file, packageId, chosen, locked, adopted);
3054
- journal.finalize();
3055
- } catch (e) { abortRun(journal, e, bail); return; }
3056
-
3057
- if (JSON_MODE) {
3058
- jsonOk({
3059
- action: sub, package: packageId, template: chosen.template, file, changed,
3060
- baseAdvanced: true, applied: merged.applied, backup: changed ? backupFile : null,
3061
- adoptedBase: join(adoptedTemplateDir(dir, packageId, chosen.template), "oats-config.yaml"),
3062
- contentIntegrity: chosen.contentIntegrity,
3063
- });
3064
- return;
3065
- }
3066
- if (changed) console.log(`Applied ${merged.applied.length} change region(s) to ${shortPath(file)}; previous contents saved at ${shortPath(backupFile)}.`);
3067
- else console.log(`No bytes changed in ${shortPath(file)} — you kept every local choice.`);
3068
- console.log(`Adopted base advanced to ${packageId}:${chosen.template}, so these decisions will not be asked again. Local edits outside the applied regions are untouched.`);
3069
- }
3070
-
3071
- /** Open the run journal with the command's one-envelope guarantee intact. */
3072
- function openJournal(dir, bail) {
3073
- try { return beginRunJournal(dir); }
3074
- catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); throw e; }
3075
- }
3076
-
3077
- /** Undo a failed config mutation and report truthfully. */
3078
- function abortRun(journal, e, bail) {
3079
- const report = journal.rollback();
3080
- bail(e.code || "E_CONFIG_WRITE_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
3081
- }
3082
-
3083
- /** Write the adopted base + metadata for the template just synced against, and
3084
- * retire any previously adopted base so exactly one survives. */
3085
- function recordAdoption(dir, file, packageId, chosen, locked, previous) {
3086
- const written = writeAdoptedTemplate(dir, file, {
3087
- package: packageId, template: chosen,
3088
- root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
3089
- }, { writeConfig: false });
3090
- if (previous && (previous.package !== packageId || previous.template !== chosen.template)) {
3091
- rmSync(previous.dir, { recursive: true, force: true });
3092
- const parent = dirname(previous.dir);
3093
- try { if (!readdirSync(parent).length) rmSync(parent, { recursive: true, force: true }); } catch { /* sibling templates remain */ }
3094
- }
3095
- return written;
3096
- }
3097
-
3098
- /** Read one line from the terminal (human confirmation paths only). */
3099
- function promptLine(question) {
3100
- process.stdout.write(question);
3101
- const buf = Buffer.alloc(1024);
3102
- let read = 0;
3103
- try { read = readSync(0, buf, 0, buf.length, null); } catch { return ""; }
3104
- return buf.subarray(0, read).toString("utf8").replace(/\n.*$/s, "");
3105
- }
3106
-
3107
- /** One merge region, rendered for a human deciding what to do about it. */
3108
- function renderMergeRegion(r) {
3109
- const label = {
3110
- upstream: "UPSTREAM ONLY — the package template changed this; your config did not",
3111
- local: "LOCAL ONLY — you changed this; the package template did not (it stays)",
3112
- conflict: "CONFLICT — both changed this; an explicit choice is required",
3113
- agreed: "ALREADY AGREED — you and the package made the same change",
3114
- }[r.kind];
3115
- console.log(`[${r.id}] line ${r.local.start + 1}: ${label}`);
3116
- const block = (title, text) => {
3117
- if (!text) { console.log(` ${title}: (nothing)`); return; }
3118
- for (const line of text.replace(/\n$/, "").split("\n")) console.log(` ${title}: ${line}`);
3119
- };
3120
- if (r.kind !== "local") block("package", r.template.text);
3121
- if (r.kind !== "upstream") block("yours ", r.local.text);
3122
- console.log("");
3123
- }
3124
-
3125
- /** oats list — installed packages, exported capabilities, scopes. */
3126
- /** `oats catalog [--json]` — the effective official package catalog, read-only.
3127
- * Identity/discovery for consumers that cannot import the kernel (Desktop):
3128
- * never acquires, never trusts, never fetches. */
3129
- /** `oats instance <git|diff> <instance>` — K1: read-only Git observation of one
3130
- * instance's work tree. The instance is addressed qualified: an explicit
3131
- * --home, or a name under the --dir scope (team roots included) that resolves
3132
- * to exactly one home; several homes refuse with every candidate named. */
3133
- function instanceCmd() {
3134
- const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
3135
- const sub = args[1], name = args[2];
3136
- const usage = "usage: oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
3137
- if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
3138
- dropAmbientRoot();
3139
- if (sub === "events") {
3140
- // K7: typed producer events, bounded window; nothing inferred.
3141
- const homeOpt = flag("home"); if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3142
- let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
3143
- const limit = flag("limit"); const since = flag("since");
3144
- if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
3145
- try {
3146
- const { resolveInstance } = await_import_lifecycle();
3147
- // K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
3148
- // exactly this name under the scope (E_HOME_MISMATCH otherwise).
3149
- const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
3150
- const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
3151
- if (JSON_MODE) { jsonOk(ev); return; }
3152
- console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
3153
- for (const e of ev.events) console.log(` ${e.at ?? "?"} ${e.kind.padEnd(20)} ${e.producer}${e.data ? ` ${JSON.stringify(e.data).slice(0, 120)}` : ""}`);
3154
- return;
3155
- } catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
3156
- }
3157
- if (sub === "stop") {
3158
- // K3: plan → apply. The plan is what a confirmation shows; apply carries
3159
- // its revision back and refuses if reality moved.
3160
- const homeOpt = flag("home");
3161
- if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3162
- let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
3163
- const recursive = !args.includes("--no-recursive");
3164
- const wantPlan = args.includes("--plan"), wantApply = args.includes("--apply");
3165
- if (wantPlan === wantApply) return bail("E_BAD_ARGS", "stop needs exactly one of --plan or --apply");
3166
- try {
3167
- if (wantPlan) {
3168
- const plan = planStop(dirFlag(), root, name, { home: homeOpt, recursive });
3169
- if (JSON_MODE) { jsonOk(plan); return; }
3170
- console.log(`stop ${name}${recursive ? " (and recorded children)" : ""} — plan ${plan.planRevision}`);
3171
- for (const t of plan.targets) console.log(` ${" ".repeat(t.depth)}${t.instance}: session ${t.session.state}${t.work.observed ? `, ${t.work.changed} changed / ${t.work.untracked} untracked on ${t.work.branch ?? "detached"}` : ", work not observed"}${t.midTask === true ? " — mid-task" : t.midTask === "unknown" ? " — activity unknown" : ""}`);
3172
- for (const n of plan.notes) console.log(` note: ${n}`);
3173
- console.log(`apply with: oats instance stop ${name} --apply --plan-revision ${plan.planRevision} --idempotency-key <key>`);
3174
- return;
3175
- }
3176
- const rev = flag("plan-revision"), key = flag("idempotency-key"), grace = flag("grace-ms");
3177
- if (rev === true || key === true || grace === true) return bail("E_BAD_ARGS", usage);
3178
- const receipt = applyStop(dirFlag(), root, name, { home: homeOpt, recursive, planRevision: rev, idempotencyKey: key, ...(grace !== undefined ? { graceMs: Number(grace) } : {}) });
3179
- if (JSON_MODE) { jsonOk(receipt); return; }
3180
- for (const r of receipt.results) console.log(` ${r.instance}: ${r.ok ? (r.stopped ? "stopped" : `already ${r.state}`) : `${r.code} — ${r.message}`}`);
3181
- console.log(receipt.ok ? `stopped${receipt.replayed ? " (replayed receipt)" : ""}; home, work, transcript and launch configuration retained — restart with \`oats session restart\`` : "some targets are still running; nothing was escalated");
3182
- if (!receipt.ok) process.exit(1);
3183
- return;
3184
- } catch (e) { return bail(e.code || "E_LIFECYCLE_FAILED", e.message, e.plan ? { plan: e.plan } : e.candidates ? { candidates: e.candidates } : undefined); }
3185
- }
3186
- let home = flag("home");
3187
- if (home === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3188
- if (home !== undefined && !isAbsolute(home)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
3189
- if (home === undefined) {
3190
- let root;
3191
- try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
3192
- let r; try { r = resolveOatsConfig(dirFlag()); } catch (e) { return bail(e.code || "E_CONFIG_BROKEN", e.message); }
3193
- const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])].map((p) => realOrResolved(p)))];
3194
- const candidates = [];
3195
- for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
3196
- if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
3197
- if (candidates.length > 1) return bail("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`, { candidates });
3198
- home = candidates[0].home;
3199
- } else if (basename(home) !== name) return bail("E_HOME_MISMATCH", `--home ${home} is not the home of instance ${JSON.stringify(name)}`);
3200
- try {
3201
- if (sub === "git") {
3202
- const observed = observeInstanceGit(home);
3203
- if (JSON_MODE) { jsonOk(observed); return; }
3204
- const o = observed.observation;
3205
- console.log(`${observed.instance} — ${shortPath(o.worktree)} @ ${o.branch ?? (o.detached ? `detached ${o.revision.slice(0, 12)}` : "unborn")}`);
3206
- console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
3207
- console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
3208
- console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
3209
- for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
3210
- for (const n of observed.notes) console.log(` note: ${n}`);
3211
- return;
3212
- }
3213
- const fileId = flag("file"), revision = flag("revision"), indexRevision = flag("index-revision");
3214
- if (fileId === true || revision === true || indexRevision === true) return bail("E_BAD_ARGS", usage);
3215
- const d = diffInstanceFile(home, { fileId, revision, indexRevision });
3216
- if (JSON_MODE) { jsonOk(d); return; }
3217
- console.log(`${d.file.origPath ? `${d.file.origPath} -> ` : ""}${d.file.path} (${d.file.kind}, against ${d.against})${d.binary ? " [binary]" : ""}${d.truncated ? ` [truncated at ${d.limit} bytes]` : ""}`);
3218
- if (!d.binary) process.stdout.write(d.patch);
3219
- } catch (e) {
3220
- bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
3221
- }
3222
- }
3223
- /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
3224
- function readinessCmd() {
3225
- const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
3226
- dropAmbientRoot();
3227
- // A captured incarnation's readiness comes from its retained resolution, not
3228
- // from the current configuration this command reads; refuse before inspecting.
3229
- const homeArg = flag("home");
3230
- if (homeArg && homeArg !== true) {
3231
- let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
3232
- if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
3233
- }
3234
- const inspect = computeInspect({ onFail: bail });
3235
- if (!inspect) return;
3236
- const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
3237
- const verify = args.includes("--verify-signatures");
3238
- let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
3239
- const deploymentDir = inspect.scope?.context ?? null;
3240
- // Echo the exact selector this read was made with, so a consumer can bind the
3241
- // result to its own admitted target without inventing a revision.
3242
- // Every field is the argument AS GIVEN (no realpath): a consumer compares it
3243
- // byte-exact with what it sent. The canonical scope is subject.context.
3244
- const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
3245
- const agentsRootArg = given("agents-root"), dirArg = given("dir");
3246
- const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
3247
- : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
3248
- : { kind: "scope", dir: dirArg };
3249
- const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
3250
- if (args.includes("--policy")) {
3251
- const homeOpt = flag("home");
3252
- let meta = null;
3253
- if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
3254
- readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
3255
- readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
3256
- }
3257
- if (JSON_MODE) { jsonOk(readiness); return; }
3258
- console.log(`readiness — ${readiness.subject.kind === "soul" ? `soul ${readiness.subject.name}` : shortPath(readiness.subject.context)}: ${readiness.summary.ready ? "READY" : `${readiness.summary.fail} failing, ${readiness.summary.unknown} unknown of ${readiness.summary.required} required`}`);
3259
- for (const [name, check] of Object.entries(readiness.checks)) {
3260
- console.log(` ${name}: ${check.status}`);
3261
- for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.signature ? ` · signature ${i.signature.status}${i.signature.signer?.label ? ` by ${i.signature.signer.label}` : ""}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
3262
- }
3263
- if (readiness.policy) console.log(` policy: child spawns ${readiness.policy.childSpawns.allowed ? "allowed" : "disabled"} (${readiness.policy.childSpawns.origin.kind}${readiness.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${readiness.policy.worktrees.allowed === null ? "unknown" : readiness.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
3264
- for (const n of readiness.notes) console.log(` note: ${n}`);
3265
- }
3266
- function catalogCmd() {
3267
- const described = describeOfficialCatalog();
3268
- if (JSON_MODE) { jsonOk(described); return; }
3269
- console.log(`Official package catalog (${described.catalog.origin}: ${shortPath(described.catalog.file)})`);
3270
- for (const p of described.packages) console.log(` ${p.package} ${p.url ?? "?"}@${p.ref ?? "?"} path: ${p.path}`);
3271
- if (described.capabilityAliases.length) {
3272
- console.log("Capability aliases:");
3273
- for (const a of described.capabilityAliases) console.log(` ${a.capability} -> ${a.package}${a.capabilityInPackage !== a.capability ? ` (exports ${a.capabilityInPackage})` : ""}${a.available ? "" : " [package not in catalog]"}`);
3274
- }
3275
- console.log("Catalog identity grants no executable trust; acquire with `oats install <package>` and approve separately.");
3276
- }
3277
- function listCmd() {
3278
- const dir = dirFlag();
3279
- // FAIL-CLOSED (maintainer finding 3): list RAISES on invalid locks — an
3280
- // invalid lock must never render as usable/absent data.
3281
- let pkgs, locks;
3282
- try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); }
3283
- catch (e) { JSON_MODE ? jsonFail(e.code || "invalid-lock", e.message || e) : die(e.message); return; }
3284
- // Packages are TRANSPORT; capabilities are what is installed. So the listing
3285
- // is capability-first: every row names its own provider, artifact, integrity,
3286
- // trust and health, and the package rows keep only what the transport itself
3287
- // pins. Trust is per capability — there is no package-level approval to list.
3288
- const capabilities = [];
3289
- for (const p of pkgs) {
3290
- const rows = levelRows(locks, p.level);
3291
- for (const c of p.capabilities) {
3292
- const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
3293
- capabilities.push({
3294
- capability: c.id, version: c.version || null, package: p.package, level: p.level,
3295
- path: c.path || null, dir: h.dir, integrity: c.integrity || null,
3296
- installedIntegrity: h.integrity ?? null,
3297
- layer: c.manifest?.layer || null, trusted: c.trusted === true, installed: c.installed,
3298
- executableSurface: {
3299
- commands: Object.keys(c.manifest?.commands || {}),
3300
- hooks: Object.keys(c.manifest?.hooks || {}),
3301
- environment: [...(c.manifest?.environment || [])],
3302
- },
3303
- status: h.status, code: h.code, detail: h.detail,
3304
- });
3305
- }
3306
- }
3307
- if (JSON_MODE) {
3308
- jsonOk({
3309
- packages: pkgs.map((p) => ({ package: p.package, version: p.version, level: p.level, source: p.source || null, path: p.path || null, commit: p.commit || null, integrity: p.integrity || null, locked: p.locked, dependencies: p.dependencies, capabilities: p.capabilities.map((c) => c.id) })),
3310
- capabilities,
3311
- legacy: locks.legacy.map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion, capabilities: Object.keys(l.capabilities) })),
3312
- });
3313
- return;
3314
- }
3315
- if (!pkgs.length) console.log("No installed packages in this config chain.");
3316
- const byPackage = new Map();
3317
- for (const c of capabilities) {
3318
- if (!byPackage.has(c.package)) byPackage.set(c.package, []);
3319
- byPackage.get(c.package).push(c);
3320
- }
3321
- for (const p of pkgs) {
3322
- console.log(`${p.package}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]${p.locked ? "" : " UNLOCKED (no lock entry — reacquire)"}`);
3323
- if (p.source) console.log(` source: ${p.source} path: ${p.path || "?"} commit: ${p.commit || "?"}`);
3324
- for (const c of byPackage.get(p.package) || []) {
3325
- const executable = c.executableSurface.commands.length || c.executableSurface.hooks.length || c.executableSurface.environment.length;
3326
- const trust = executable ? (c.trusted ? " [trusted]" : " [executable — needs oats trust]") : "";
3327
- console.log(` capability ${c.capability}${c.layer ? ` layer: ${c.layer}` : ""}${trust}`);
3328
- // A capability whose bytes or provenance disagree with the lock is named
3329
- // as broken HERE — never rendered as an ordinary usable row.
3330
- if (c.status !== "ok" && c.status !== "untrusted") console.log(` ${c.status.toUpperCase()}: ${c.detail}`);
3331
- }
3332
- if (p.dependencies.length) console.log(` depends on: ${p.dependencies.join(", ")}`);
3333
- }
3334
- for (const l of locks.legacy) console.log(`Legacy capability locks (lockfileVersion ${l.lockfileVersion ?? 1}) in ${shortPath(l.file)}: ${Object.keys(l.capabilities).join(", ")} — \`oats migrate\` maps them to packages`);
3335
- }
3336
-
3337
- /** oats remove <package> — refuses while config or dependent packages reference it. */
3338
- function removeCmd() {
3339
- const id = args[1];
3340
- if (!id || id.startsWith("--")) JSON_MODE ? jsonFail("E_USAGE", "usage: oats remove <package> [--dir <dir>]") : die("usage: oats remove <package> [--dir <dir>]");
3341
- const dir = dirFlag();
3342
- let r;
3343
- try { r = removePackage(dir, id); } catch (e) { cmdFail(e.code || "remove-blocked", e.message || e); return; }
3344
- if (JSON_MODE) { jsonOk(r); return; }
3345
- // There is no package directory to name — a package is transport, and what
3346
- // actually leaves the disk is its materialized capability artifacts.
3347
- console.log(`Removed package ${r.package} from ${shortPath(r.lockFile)}.`);
3348
- console.log(r.capabilities.length
3349
- ? ` capabilities de-materialized: ${r.capabilities.join(", ")}`
3350
- : " it supplied no capabilities at this scope.");
3351
- }
3352
-
3353
- /** The team boundary a guided migration walks, when the scope declares one.
3354
- * A config the kernel refuses to resolve is not a reason to abort a migration
3355
- * that only reads locks — discovery falls back to the explicit scope and says so. */
3356
- function migrationTeamScope(dir, warnings) {
3357
- try { return resolveOatsConfig(dir)?.team?.scope || undefined; }
3358
- catch (e) { warnings.push(`team boundary not resolved from ${shortPath(dir)} (${e.message}) — discovery covers this scope and its lock-owning ancestors only`); return undefined; }
3359
- }
3360
-
3361
- const migratePlanRow = (s) => ({
3362
- capability: s.capabilityId, action: s.action,
3363
- package: s.package?.id || null, spec: s.package?.spec || null, via: s.package?.via || null,
3364
- migratesTo: s.migratesTo || null,
3365
- source: s.v1?.source || null, reason: s.reason || null, note: s.note || null,
3366
- });
3367
-
3368
- /** `oats migrate --official` / `--recursive` — the guided existing-user upgrade.
3369
- *
3370
- * Plans EVERY visible lock-owning scope first (deterministic, side-effect
3371
- * free), prints the complete per-scope plan, then applies scope by scope. Each
3372
- * scope keeps the engine's transactional guarantee on its own: one scope's
3373
- * failure leaves that scope byte-identical, never stops the others from being
3374
- * reported truthfully, and makes the aggregate result nonzero. */
3375
- function guidedMigrateCmd({ dir, dryRun, official, recursive }) {
3376
- const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
3377
- const opts = official ? { official: true } : {};
3378
- const warnings = [];
3379
- const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
3380
- let scopes;
3381
- try {
3382
- scopes = recursive
3383
- ? discoverMigrationScopes(dir, { teamScope })
3384
- : (existsSync(join(dir, OATS_LOCK_FILE)) ? [resolve(dir)] : []);
3385
- } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
3386
- // Un-migrated OAS scopes are invisible to lock discovery (they own no
3387
- // oats-lock.json), and silence would read as "nothing to migrate" — the
3388
- // exact false success this probe exists to prevent (aweb-abfy.1).
3389
- const oasScopes = recursive ? discoverOasScopes(dir, { teamScope }) : detectOasScopes(dir);
3390
-
3391
- // ---- plan every scope BEFORE touching any of them ----
3392
- const planned = [];
3393
- for (const scope of scopes) {
3394
- const file = join(scope, OATS_LOCK_FILE);
3395
- try {
3396
- const { plan, warnings: w } = migrateLegacyLock(scope, opts);
3397
- const held = plan.filter((s) => s.action === "hold");
3398
- const acquire = plan.filter((s) => s.action === "acquire");
3399
- const formatOnly = plan.some((s) => s.action === "convert-format");
3400
- const keep = plan.filter((s) => s.action === "retain" || s.action === "manual");
3401
- // Both modes are ALL-OR-NOTHING: a v2 lock has no residue container, so a
3402
- // scope converts completely or stays v1 in full. `keep` entries therefore
3403
- // make a scope unconvertible rather than partially convertible — apply
3404
- // refuses it, and the plan says so rather than promising "ready".
3405
- // Official mode also never rewrites a scope it has no official work in.
3406
- const convertible = acquire.length || formatOnly || (!official && plan.length);
3407
- const status = held.length ? "held"
3408
- : (convertible && !keep.length) ? "ready"
3409
- : convertible ? "blocked"
3410
- : "nothing";
3411
- planned.push({ scope, file, status, plan, acquire, keep, held, warnings: w });
3412
- } catch (e) {
3413
- planned.push({ scope, file, status: "failed", plan: [], acquire: [], keep: [], held: [], warnings: [], error: { code: e.code || "invalid-lock", message: String(e.message || e) } });
3414
- }
3415
- }
3416
- const planRows = planned.map((p) => ({
3417
- level: p.scope, levelKind: levelOf(p.scope), file: p.file, status: p.status,
3418
- plan: p.plan.map(migratePlanRow), warnings: p.warnings, error: p.error || null,
3419
- }));
3420
-
3421
- const actionable = planned.filter((p) => p.status === "ready" || p.status === "format-only");
3422
- out(`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} — ${scopes.length} lock-owning scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
3423
- for (const w of warnings) out(`WARNING: ${w}`);
3424
- if (!scopes.length) out(oasScopes.length
3425
- ? " (no oats-lock.json found — but this is NOT an empty scope: un-migrated OAS files are present, see below)"
3426
- : " (no oats-lock.json found — nothing to migrate)");
3427
- for (const f of oasScopes) {
3428
- out(`\n ${shortPath(f.dir)} UN-MIGRATED OAS SCOPE (${f.files.join(", ")})`);
3429
- out(` HELD ${OAS_SCOPE_REMEDY}`);
3430
- }
3431
- for (const p of planned) {
3432
- out(`\n ${shortPath(p.scope)} [${levelOf(p.scope)}] ${shortPath(p.file)}`);
3433
- if (p.status === "failed") { out(` ERROR ${p.error.message} [${p.error.code}]`); continue; }
3434
- for (const s of p.plan) {
3435
- if (s.action === "convert-format") out(` format ${s.note}`);
3436
- else if (s.action === "acquire") out(` migrate ${s.capabilityId} → package ${s.package.id || s.package.spec}${s.migratesTo ? ` (catalog alias: package ${s.package.id} exports ${s.migratesTo}, replacing ${s.capabilityId})` : s.package.via === "alias" ? ` (catalog alias: package ${s.package.id} exports ${s.capabilityId})` : s.package.via === "identity" ? " (official catalog)" : ""}`);
3437
- else if (s.action === "hold") out(` HELD ${s.capabilityId} — ${s.reason}`);
3438
- else out(` keep ${s.capabilityId}${s.v1?.source ? ` (${s.v1.source})` : ""} — not converted, entry kept unchanged`);
3439
- }
3440
- if (p.status === "nothing") out(" (nothing to migrate at this scope)");
3441
- if (p.status === "blocked") {
3442
- out(` BLOCKED this scope mixes convertible work with ${p.keep.length} entr${p.keep.length === 1 ? "y" : "ies"} that must stay lockfileVersion 1`);
3443
- out(" a capability-materialization lock has no place for them, so converting the rest would drop them — the WHOLE scope stays v1 and keeps working");
3444
- }
3445
- if (p.status === "ready") {
3446
- const renames = p.plan.filter((s) => s.migratesTo);
3447
- if (renames.length) out(` config ${shortPath(join(p.scope, "oats-config.yaml"))} is NOT rewritten — but renamed ids must be updated by hand after applying: ${renames.map((s) => `${s.capabilityId} → ${s.migratesTo}`).join(", ")}`);
3448
- else out(` config ${shortPath(join(p.scope, "oats-config.yaml"))} is NOT rewritten — capability ids, layers, targets, settings, exclusions and overrides stay valid (packages export the same ids)`);
3449
- out(" trust executable approvals are NOT carried over — they are re-earned after migrating (exact commands below)");
3450
- }
3451
- for (const w of p.warnings) out(` WARNING: ${w}`);
3452
- }
3453
-
3454
- const result = {
3455
- mode: official ? "official" : "generic", recursive, dryRun,
3456
- boundary: resolve(dir), scopes: planRows, oasScopes, oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
3457
- trust: [], requirements: [], nextCommands: [], warnings,
3458
- };
3459
- if (dryRun) {
3460
- const failed = planned.filter((p) => p.status === "failed");
3461
- const held = planned.filter((p) => p.status === "held");
3462
- result.nextCommands = actionable.length ? [`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`] : [];
3463
- // A held or unplannable scope is NOT a ready migration: the dry run says so
3464
- // with a nonzero result in both modes, so automation can never read
3465
- // "planned successfully" as "this deployment can migrate now"
3466
- // (reviewer-90dbb36). The complete plan travels under error.details.
3467
- const mixed = planned.filter((p) => p.status === "blocked");
3468
- const blocked = [
3469
- ...(held.length ? [`${held.length} scope${held.length > 1 ? "s" : ""} held (no official package mapping yet)`] : []),
3470
- ...(mixed.length ? [`${mixed.length} scope${mixed.length > 1 ? "s" : ""} blocked (entries that must stay lockfileVersion 1)`] : []),
3471
- ...(failed.length ? [`${failed.length} scope${failed.length > 1 ? "s" : ""} could not be planned`] : []),
3472
- ...(oasScopes.length ? [`${oasScopes.length} un-migrated OAS scope${oasScopes.length > 1 ? "s" : ""} detected (${oasScopes.map((f) => shortPath(f.dir)).join(", ")}) — no oas-* name is recognized and there is no automatic path yet; see docs/migration-from-oas.md`] : []),
3473
- ];
3474
- if (JSON_MODE) {
3475
- if (blocked.length) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_MIGRATE_FAILED", message: `${blocked.join("; ")} (${actionable.length} ready)`, details: result } })); process.exit(1); }
3476
- jsonOk(result);
3477
- return;
3478
- }
3479
- out(`\nDry run — nothing was changed. ${actionable.length} scope${actionable.length === 1 ? "" : "s"} ready${held.length ? `, ${held.length} held` : ""}${mixed.length ? `, ${mixed.length} blocked` : ""}${failed.length ? `, ${failed.length} failed` : ""}.`);
3480
- if (actionable.length) out(`Apply with: oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
3481
- if (held.length) out("Held scopes stay on their v1 locks and their legacy capabilities keep working — re-run when the catalog publishes their packages.");
3482
- if (mixed.length) out("Blocked scopes stay on their v1 locks IN FULL and keep working — migration is all-or-nothing because a v2 lock has no place for an unconverted entry.");
3483
- if (blocked.length) die(`${blocked.join("; ")} (${actionable.length} ready)`);
3484
- return;
3485
- }
3486
-
3487
- // ---- apply, scope by scope (each independently transactional) ----
3488
- const failures = [];
3489
- // An un-migrated OAS scope is a failure, not an absence: apply must never
3490
- // report overall success while one sits in the migrated universe.
3491
- for (const f of oasScopes) {
3492
- failures.push({ scope: f.dir, code: "oas-scope-unmigrated", message: `un-migrated OAS scope (${f.files.join(", ")}) — ${OAS_SCOPE_REMEDY}` });
3493
- }
3494
- for (const [i, p] of planned.entries()) {
3495
- const row = planRows[i]; // planRows is built from planned, in order
3496
- if (p.status === "failed") { row.status = "failed"; failures.push({ scope: p.scope, code: p.error.code, message: p.error.message }); continue; }
3497
- if (p.status === "held") {
3498
- row.status = "held";
3499
- failures.push({ scope: p.scope, code: "official-mapping-unavailable", message: `held: ${p.held.map((s) => `${s.capabilityId} (${s.reason})`).join("; ")}` });
3500
- out(`\nHELD ${shortPath(p.scope)} — left unchanged; its legacy capabilities keep working`);
3501
- continue;
3502
- }
3503
- if (p.status === "nothing") {
3504
- // No official work here, so nothing is applied and nothing is rewritten.
3505
- // Say what the scope KEPT — `retained`, never `residue`: these entries
3506
- // were not left beside a conversion, there simply was no conversion.
3507
- row.status = "skipped";
3508
- if (p.keep.length) row.retained = p.keep.map((k) => k.capabilityId).filter(Boolean);
3509
- continue;
3510
- }
3511
- let r;
3512
- try { r = applyLegacyLockMigration(p.scope, opts); }
3513
- catch (e) {
3514
- row.status = "failed";
3515
- row.error = { code: e.code || "legacy-lock", message: String(e.message || e) };
3516
- failures.push({ scope: p.scope, code: row.error.code, message: row.error.message });
3517
- out(`\nFAILED ${shortPath(p.scope)} — ${row.error.message}`);
3518
- continue;
3519
- }
3520
- row.status = r.skipped ? "skipped" : r.formatConverted ? "format-converted" : "migrated";
3521
- row.migrated = r.migrated;
3522
- // `retained` exists only for a SKIPPED scope left entirely on v1; a scope
3523
- // that converts leaves nothing behind, and a mixed one is refused above.
3524
- if (r.retained) row.retained = r.retained;
3525
- row.warnings = r.warnings;
3526
- for (const t of r.trust || []) result.trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(p.scope)}` });
3527
- out(`\n ${shortPath(p.scope)}:`);
3528
- for (const m of r.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
3529
- for (const c of r.retained || []) out(` retained ${c} (this scope stays lockfileVersion 1, unchanged)`);
3530
- for (const w of r.warnings) out(` WARNING: ${w}`);
3531
- if (r.formatConverted) out(` format empty lockfileVersion 1 file → canonical v2`);
3532
- else if (!r.skipped) out(` ${shortPath(r.file)} is now lockfileVersion 2 — config activation (from: installed) is unchanged`);
3533
- }
3534
-
3535
- // ---- exact next commands: trust first, then the requirement/install pass ----
3536
- const migratedScopes = planRows.filter((r) => r.status === "migrated").map((r) => r.level);
3537
- let requirements = [];
3538
- try { requirements = migratedScopes.length ? aggregateMissingRequirements(migratedScopes) : []; }
3539
- catch (e) { result.warnings.push(`host requirements not aggregated: ${e.message}`); }
3540
- result.requirements = requirements.map((req) => ({
3541
- command: req.command, requestedBy: req.requestedBy,
3542
- consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
3543
- ? `oats install --accept-requirement ${req.command} --dir ${shellQuote(dir)}` : null,
3544
- }));
3545
- result.nextCommands = [
3546
- ...result.trust.map((t) => t.command),
3547
- ...result.requirements.filter((q) => q.consentCommand).map((q) => q.consentCommand),
3548
- `oats install --dir ${shellQuote(dir)}`,
3549
- ];
3550
-
3551
- if (JSON_MODE) {
3552
- if (failures.length) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_MIGRATE_FAILED", message: `${failures.length} scope${failures.length > 1 ? "s" : ""} not migrated (${planRows.filter((r) => r.status === "migrated").length} migrated)`, details: result } })); process.exit(1); }
3553
- jsonOk(result);
3554
- return;
3555
- }
3556
- out("\nNext steps:");
3557
- if (result.trust.length) {
3558
- out(" 1. Review and approve the executable surfaces (approvals are never carried over):");
3559
- for (const t of result.trust) out(` ${t.command}`);
3560
- } else out(" 1. No executable surfaces to approve.");
3561
- for (const q of result.requirements) {
3562
- if (q.consentCommand) out(` * Missing host command ${q.command}: ${q.consentCommand}`);
3563
- }
3564
- out(` 2. Verify the runtime closure and host requirements (already-installed requirements are not reinstalled):`);
3565
- out(` oats install --dir ${shellQuote(dir)}`);
3566
- if (failures.length) {
3567
- out("\nFailures by scope:");
3568
- for (const f of failures) out(` ${shortPath(f.scope)}: ${f.message} [${f.code}]`);
3569
- die(`${failures.length} scope${failures.length > 1 ? "s" : ""} not migrated (${planRows.filter((r) => r.status === "migrated").length} migrated)`);
3570
- }
3571
- }
3572
-
3573
- /** `oats migrate --from-oas` — one transactional conversion per scope: rename
3574
- * the OAS-named artifacts (breaks 1-3 of docs/migration-from-oas.md), then
3575
- * chain the guided v1→v2 lock conversion under the SAME journal, so a failure
3576
- * in either phase restores the original OAS bytes. Break 4 (stale v1
3577
- * integrity) is resolved by re-acquisition, never by recomputing integrity. */
3578
- function fromOasCmd({ dir, dryRun, recursive }) {
3579
- const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
3580
- const warnings = [];
3581
- const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
3582
- let scopes;
3583
- if (recursive) scopes = discoverOasScopes(dir, { teamScope }).map((f) => f.dir);
3584
- else {
3585
- const chain = detectOasScopes(dir);
3586
- scopes = chain.filter((f) => f.dir === resolve(dir)).map((f) => f.dir);
3587
- if (!scopes.length && chain.length) {
3588
- cmdFail("E_BAD_ARGS", `no OAS scope files at ${resolve(dir)}, but an ancestor has them (${chain.map((f) => shortPath(f.dir)).join(", ")}) — run with --dir <that scope>, or --recursive to convert every visible OAS scope`);
3589
- return;
3590
- }
3591
- }
3592
- if (!scopes.length) {
3593
- // Idempotency contract: a second run finds nothing and says so, exit 0.
3594
- if (JSON_MODE) { jsonOk({ mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: [], trust: [], nextCommands: [], warnings }); return; }
3595
- console.log("no oas-config.yaml / oas-lock.json found — nothing to migrate from OAS");
3596
- return;
3597
- }
3598
-
3599
- const results = [];
3600
- const trust = [];
3601
- let failures = 0;
3602
- for (const scope of scopes) {
3603
- const plan = planFromOasScope(scope);
3604
- const row = { scope, status: null, steps: plan.steps.map((s) => ({ kind: s.kind, from: s.from, to: s.to, note: s.note })), errors: plan.errors, plan: [], migrated: [], warnings: [] };
3605
- results.push(row);
3606
- if (plan.errors.length) { row.status = "failed"; failures++; continue; }
3607
- if (dryRun) {
3608
- // Phase-2 preview against a temp mirror of the lock alone — guided
3609
- // planning reads nothing else, so the preview is exact and touch-free.
3610
- const lockStep = plan.steps.find((s) => s.to.endsWith(OATS_LOCK_FILE));
3611
- if (lockStep) {
3612
- const mirror = mkdtempSync(join(tmpdir(), "oats-from-oas-plan-"));
3613
- try {
3614
- copyFileSync(lockStep.from, join(mirror, OATS_LOCK_FILE));
3615
- const { plan: mplan, warnings: mw } = migrateLegacyLock(mirror, { official: true });
3616
- row.plan = mplan.map(migratePlanRow);
3617
- row.warnings = mw;
3618
- row.status = mplan.some((s) => s.action === "hold" || s.action === "manual") ? "held" : "ready";
3619
- if (row.status === "held") failures++;
3620
- } catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; }
3621
- finally { rmSync(mirror, { recursive: true, force: true }); }
3622
- } else row.status = "ready";
3623
- continue;
3624
- }
3625
- let journal;
3626
- try { journal = applyFromOasScope(scope, plan); }
3627
- catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; continue; }
3628
- try {
3629
- const r = applyLegacyLockMigration(scope, { official: true });
3630
- journal.finalize();
3631
- row.status = "migrated";
3632
- row.migrated = r.migrated;
3633
- // Phase 1 already rewrote the config's capability ids, so the guided
3634
- // "update references in oats-config.yaml" warnings are satisfied here.
3635
- row.warnings = r.warnings.filter((w) => !/update references in oats-config\.yaml/.test(w));
3636
- for (const t of r.trust || []) trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(scope)}` });
3637
- } catch (e) {
3638
- journal.rollback();
3639
- row.status = "failed";
3640
- row.errors.push(`${String(e.message || e)} — scope restored to its original OAS state`);
3641
- failures++;
3642
- }
3643
- }
3644
-
3645
- out(`oats migrate --from-oas${recursive ? " --recursive" : ""}${dryRun ? " --dry-run" : ""} — ${scopes.length} OAS scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
3646
- for (const w of warnings) out(`WARNING: ${w}`);
3647
- for (const row of results) {
3648
- out(`\n ${shortPath(row.scope)} [${row.status}]`);
3649
- for (const s of row.steps) out(` ${s.kind === "rewrite" ? "rewrite " : "rename "} ${shortPath(s.from)} → ${shortPath(s.to)} (${s.note})`);
3650
- for (const p of row.plan) out(` migrate ${p.capability} → package ${p.package}${p.migratesTo ? ` (as ${p.migratesTo})` : ""} [${p.action}]`);
3651
- for (const m of row.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
3652
- for (const e of row.errors) out(` ERROR ${e}`);
3653
- for (const w of row.warnings) out(` WARNING: ${w}`);
3654
- }
3655
- const nextCommands = [...trust.map((t) => t.command), ...(results.some((r) => r.status === "migrated") ? [`oats install --dir ${shellQuote(dir)}`] : [])];
3656
- const result = { mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: results, trust, nextCommands, warnings };
3657
- if (JSON_MODE) {
3658
- if (failures) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_FROM_OAS_FAILED", message: `${failures} scope${failures > 1 ? "s" : ""} not converted (${results.filter((r) => r.status === "migrated" || r.status === "ready").length} ${dryRun ? "ready" : "converted"})`, details: result } })); process.exit(1); }
3659
- jsonOk(result);
3660
- return;
3661
- }
3662
- if (!dryRun && nextCommands.length) {
3663
- out("\nNext steps:");
3664
- for (const c of nextCommands) out(` ${c}`);
3665
- }
3666
- if (dryRun) out(`\nDry run — nothing was changed. Apply with: oats migrate --from-oas${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
3667
- if (failures) die(`${failures} scope${failures > 1 ? "s" : ""} not converted`);
3668
- }
3669
-
3670
- /** oats migrate — map this scope's v1 marketplace capability locks to package locks. */
3671
- function migrateCmd() {
3672
- const dir = dirFlag();
3673
- const dryRun = args.includes("--dry-run");
3674
- if (args.includes("--from-oas")) { fromOasCmd({ dir, dryRun, recursive: args.includes("--recursive") }); return; }
3675
- if (args.includes("--official") || args.includes("--recursive")) {
3676
- guidedMigrateCmd({ dir, dryRun, official: args.includes("--official"), recursive: args.includes("--recursive") });
3677
- return;
3678
- }
3679
- if (dryRun) {
3680
- let plan, warnings;
3681
- try { ({ plan, warnings } = migrateLegacyLock(dir)); }
3682
- catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
3683
- if (!plan.length) {
3684
- // "Nothing to migrate" on an OAS scope is a false success: the scope is
3685
- // not empty, it is pre-rename, and this kernel cannot read it (aweb-abfy.1).
3686
- const oas = detectOasScopes(dir);
3687
- if (oas.length) { cmdFail("oas-scope-unmigrated", `nothing this command can migrate here, but un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}`); return; }
2002
+ let lockFile;
2003
+ try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2004
+ const members = memberRows(discovery);
2005
+ const packages = packageRows(lock);
2006
+ const changes = resolved.changes;
2007
+ const items = workspaceItems(discovery, lock, { includePrivate: true });
2008
+ const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, approvalNeeded, problems };
2009
+ return { report, lock, discovery, approvalNeeded, interactive, items, lockFile, problems };
2010
+ }
2011
+
2012
+ /** The human §8 report of a sync (text mode). */
2013
+ function printSyncReport(ctx, synced) {
2014
+ const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
2015
+ const { members, packages, changes } = report;
2016
+ const disabled = new Set(ctx.local.souls?.disabled || []);
2017
+ console.log(`workspace ${discovery.workspace?.name ?? `(standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)`} (${discovery.key} @ ${short(discovery.commit)})`);
2018
+ console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
2019
+ console.log(`packages ${packages.map((p) => {
2020
+ const need = approvalNeeded.find((a) => a.id === p.id);
2021
+ return `${p.id} ${p.version} ✓ (${p.approved ? "approved" : need ? "approval needed" : "unapproved"})`;
2022
+ }).join(" ") || "(none)"}`);
2023
+ const changed = changes.filter((c) => c.to !== null && c.from !== c.to).map((c) => `${c.id} ${c.from ?? "—"} → ${c.to} (@ ${short(c.commit)})`);
2024
+ const removed = changes.filter((c) => c.to === null).map((c) => `${c.id} ${c.from} → removed`);
2025
+ console.log(`changed ${[...changed, ...removed].join(" ") || "(nothing — the lock already described this workspace)"}`);
2026
+ const memberSouls = items.souls.filter((s) => s.kind === "member");
2027
+ const externalSouls = items.souls.filter((s) => s.kind === "external");
2028
+ const privateSouls = memberSouls.filter((s) => s.private);
2029
+ const disabledHere = items.souls.filter((s) => disabled.has(s.name));
2030
+ console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${disabledHere.length} disabled here) · ${privateSouls.length} private${privateSouls.length ? ` (${privateSouls.map((s) => `${s.name}, ${memberLabel(s.repoKey)} only`).join("; ")})` : ""}`);
2031
+ const teams = new Map();
2032
+ for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
2033
+ 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); }
2034
+ console.log(`teams ${[...teams.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([team, n]) => `${team} ${n.souls} soul${n.souls === 1 ? "" : "s"}${n.capabilities ? `, ${n.capabilities} capabilit${n.capabilities === 1 ? "y" : "ies"}` : ""}`).join(" · ") || "(none)"}`);
2035
+ for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
2036
+ if (approvalNeeded.length) {
2037
+ console.log(`\nApproval needed for ${approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")} — ${interactive ? "declined; " : "not a terminal; "}the lock records them unapproved. Run \`oats sync\` in a terminal to approve their executables (spawns of souls using them are refused until then).`);
2038
+ } else console.log(`\nlock ${shortPath(lockFile)}`);
2039
+ }
2040
+
2041
+ /** `oats sync [--dir] [--json]` — contract §6. */
2042
+ async function syncCmd() {
2043
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2044
+ const ctx = workspaceContext(bail);
2045
+ const synced = await performSync(ctx, bail);
2046
+ // One envelope (or the §8 report), then the exit status: 2 = the lock is written but approvals
2047
+ // are pending. exitCode (not process.exit) lets stdout drain when it is a pipe.
2048
+ if (JSON_MODE) jsonOk(synced.report); else printSyncReport(ctx, synced);
2049
+ process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2050
+ }
2051
+
2052
+ /** Walk up from dir for oats-workspace.yaml INSIDE a Git checkout → { file, root } | null. */
2053
+ function workspaceCheckoutFrom(dir) {
2054
+ let current = resolve(dir);
2055
+ for (;;) {
2056
+ const candidate = join(current, "oats-workspace.yaml");
2057
+ if (existsSync(candidate)) {
2058
+ // The file is edited in place only when it is TRACKED by the checkout it sits in (an untracked
2059
+ // copy inside some repository is not the shared workspace file).
2060
+ let inCheckout = false;
2061
+ for (let d = current; ; d = dirname(d)) { if (existsSync(join(d, ".git"))) { inCheckout = true; break; } if (dirname(d) === d) break; }
2062
+ if (!inCheckout) return null;
2063
+ const tracked = spawnSync("git", ["-C", current, "ls-files", "--error-unmatch", "--", "oats-workspace.yaml"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
2064
+ return tracked.status === 0 ? { file: candidate, root: current } : null;
2065
+ }
2066
+ const parent = dirname(current);
2067
+ if (parent === current) return null;
2068
+ current = parent;
2069
+ }
2070
+ }
2071
+
2072
+ /** `oats package add <id> <version|git:<repo>@<ref>> | remove <id> [--dir]` — contract §6. */
2073
+ async function packageCmd() {
2074
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2075
+ const sub = args[1];
2076
+ const id = args[2];
2077
+ const value = sub === "add" && typeof args[3] === "string" && !args[3].startsWith("--") ? args[3] : undefined;
2078
+ const usage = "usage: oats package add <id> <version|git:<repo>@<ref>> [--dir <d>] | oats package remove <id> [--dir <d>]";
2079
+ if (!["add", "remove"].includes(sub) || !id || id.startsWith("--")) return bail("E_USAGE", usage);
2080
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) return bail("E_WORKSPACE_SCHEMA", `package id ${JSON.stringify(id)} must match ^[a-z0-9][a-z0-9._-]*$`, { id });
2081
+ if (sub === "add") {
2082
+ if (!value || value.startsWith("--")) return bail("E_USAGE", usage);
2083
+ const c = classifyPackageValue(value);
2084
+ if (c.problem) return bail("E_WORKSPACE_SCHEMA", `packages.${id}: ${c.problem} (a package is a bare version like v2.1.3 or git:<repo>@<ref>)`, { id, value });
2085
+ if (c.kind === "git") { try { remoteModule.parseRepoRef(c.repo); } catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); } }
2086
+ }
2087
+ const line = sub === "add" ? ` ${id}: ${YAML.stringify(value).trim()}` : null;
2088
+ const checkout = workspaceCheckoutFrom(dirFlag());
2089
+ if (!checkout) {
2090
+ // Untracked branch (the workspace file is shared through Git, not edited here). A `remove`
2091
+ // still checks the id against the DISCOVERED workspace when this directory realizes one
2092
+ // (oats-local.yaml): removing what is not declared is E_PACKAGE_MISSING, as in the tracked branch.
2093
+ if (sub === "remove") {
2094
+ let ctx = null; try { const found = loadLocal(dirFlag()); ctx = { dir: dirFlag(), localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() }; }
2095
+ catch (e) { if (e?.code !== "E_LOCAL_MISSING") return bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
2096
+ if (ctx) {
2097
+ const discovery = await discoverForCli(ctx, bail);
2098
+ const declared = discovery.standalone === true ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {});
2099
+ if (!Object.hasOwn(declared, id)) return bail("E_PACKAGE_MISSING", `packages.${id} is not declared by workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${discovery.standalone === true ? " — standalone: only the kernel's default package is requested" : ""}`, { id, workspace: discovery.key, declared: Object.keys(declared).sort() });
2100
+ }
3688
2101
  }
3689
- if (JSON_MODE) { jsonOk({ dryRun: true, plan, warnings }); return; }
3690
- if (!plan.length) { console.log("Nothing to migrate at this scope."); return; }
3691
- for (const s of plan) console.log(s.action === "convert-format" ? `${s.action.padEnd(14)} ${s.note}` : `${s.action.padEnd(10)} ${s.capabilityId}${s.package ? ` → ${s.package.spec}` : ""}`);
3692
- for (const w of warnings) console.log(`WARNING: ${w}`);
3693
- return;
3694
- }
3695
- let r;
3696
- try { r = applyLegacyLockMigration(dir); }
3697
- catch (e) {
3698
- const oas = detectOasScopes(dir);
3699
- const oasNote = oas.length ? ` NOTE: un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}` : "";
3700
- cmdFail(e.code || "legacy-lock", `${e.message || e}${oasNote}`); return;
3701
- }
3702
- if (JSON_MODE) { jsonOk(r); return; }
3703
- for (const m of r.migrated) console.log(`migrated ${m.capability} → package ${m.package}@${m.version}`);
3704
- for (const w of r.warnings) console.log(`WARNING: ${w}`);
3705
- if (r.formatConverted) { console.log(`${shortPath(r.file)} was an empty lockfileVersion 1 file — converted to canonical v2.`); return; }
3706
- if (r.file) console.log(`${shortPath(r.file)} is now lockfileVersion 2. Config activation (from: installed) is unchanged; re-run \`oats trust\` for executable capabilities — package integrity approvals are not carried over.`);
3707
- }
3708
-
3709
- /** oats update <package> — transactional package update with diff + trust reset. */
3710
- function updatePackageCmd(id) {
3711
- const dir = dirFlag();
3712
- // --to <selector>: move a catalog-sourced lock to another catalog ref
3713
- // (tag) through the same transactional update. A lock with an explicit
3714
- // selector keeps it on a plain update by design; this is the operator's
3715
- // way to advance it without remove + reinstall.
3716
- // Two spellings: `oats update <id> <id>@<selector>` (the engine's own spec
3717
- // form) or `oats update <id> --to <selector>`.
3718
- const to = flag("to");
3719
- if (to === true) { cmdFail("E_BAD_ARGS", "--to needs a catalog selector, e.g. --to v1.10.1"); return; }
3720
- if (to !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(to)) { cmdFail("E_BAD_ARGS", `--to selector ${JSON.stringify(to)} is not a catalog ref`); return; }
3721
- const positional = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
3722
- if (positional && to !== undefined) { cmdFail("E_BAD_ARGS", "give either <id>@<selector> or --to <selector>, not both"); return; }
3723
- const spec = positional || (to !== undefined ? `${id}@${to}` : undefined);
3724
- let r;
3725
- try { r = updatePackage(dir, id, spec ? { spec } : {}); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
3726
- if (JSON_MODE) { jsonOk(r); return; }
3727
- // A moved package root is reported even when the bytes are identical: the
3728
- // lock now points somewhere else in the repository, and that is exactly the
3729
- // change an operator must see (contract §7).
3730
- const pathLine = () => console.log(` package path ${r.before.path} → ${r.after.path} (the selected package root MOVED in the source)`);
3731
- if (!r.changed) {
3732
- console.log(`${r.package} is already up to date (${r.after.version}, ${r.after.integrity}).`);
3733
- if (r.pathChanged) pathLine();
2102
+ if (JSON_MODE) { jsonOk({ action: sub, id, value: value ?? null, edited: false, file: null, line: sub === "add" ? `packages:\n${line}` : null, hint: "oats-workspace.yaml is not in this checkout; commit the change in the workspace repo, then `oats sync`" }); return; }
2103
+ if (sub === "add") console.log(`oats-workspace.yaml is not in this checkout. Add under packages: in the workspace repo, then \`oats sync\`:\n\npackages:\n${line}`);
2104
+ else console.log(`oats-workspace.yaml is not in this checkout. Remove \`${id}\` from packages: in the workspace repo, then \`oats sync\`.`);
3734
2105
  return;
3735
2106
  }
3736
- console.log(`Updated ${r.package}: ${r.before.version} (${r.before.commit}) → ${r.after.version} (${r.after.commit})`);
3737
- console.log(` integrity ${r.before.integrity} → ${r.after.integrity}`);
3738
- if (r.pathChanged) pathLine();
3739
- if (r.addedCapabilities.length) console.log(` + capabilities: ${r.addedCapabilities.join(", ")}`);
3740
- if (r.removedCapabilities.length) console.log(` - capabilities: ${r.removedCapabilities.join(", ")}`);
3741
- for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
3742
- if (r.invalidatedApprovals.length) console.log(` APPROVALS INVALIDATED (integrity changed): ${r.invalidatedApprovals.join(", ")} — re-approve with \`oats trust\` after review.`);
3743
- }
3744
-
3745
- // ---------- init ----------
3746
- /**
3747
- * oats init [--raw] [--dir <dir>] [--knowledge <id>] [--messaging <id>] [--tasks <id>]
3748
- *
3749
- * Per-layer flags name a canonical capability ID or "none". A layer is filled by
3750
- * a capability already at this scope (own store first — no config exists yet, so
3751
- * the config-chain walk cannot see it), otherwise by acquiring the official
3752
- * PACKAGE that supplies it through the materialization engine. Acquisition is
3753
- * not activation, not executable trust and not requirement consent, and the
3754
- * whole run is one transaction that rolls back on any failure.
3755
- */
3756
- /** Resolve a template (name via outer-config `templates:` maps, local path, or git URL's
3757
- * main-branch oats-config.yaml) into snapshot text with a provenance comment. */
3758
- function loadTemplateConfig(spec, dir) {
3759
- // THROWS typed errors rather than exiting: `oats init --template` reports
3760
- // through the same single JSON envelope as every other init form.
3761
- const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
3762
- let source = spec;
3763
- const isDirect = /^(https?:\/\/|git@|ssh:\/\/)/.test(spec) || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~");
3764
- if (!isDirect) {
3765
- let named;
3766
- for (const cfg of configChain(dir)) {
3767
- if (cfg.templates?.[spec]) { named = { value: cfg.templates[spec], level: cfg._level }; break; }
3768
- }
3769
- if (!named) fail("E_UNKNOWN_TEMPLATE", `unknown template "${spec}" — declare it under templates: in an outer oats-config.yaml, or pass a path/git URL`);
3770
- source = /^(https?:\/\/|git@|ssh:\/\/)/.test(named.value) || named.value.startsWith("/") || named.value.startsWith("~")
3771
- ? named.value : resolve(named.level, named.value);
3772
- }
3773
- let body, provenance;
3774
- if (/^(https?:\/\/|git@|ssh:\/\/)/.test(source)) {
3775
- const tmp = mkdtempSync(join(tmpdir(), "oats-template-"));
3776
- try {
3777
- execFileSync("git", ["clone", "-q", "--depth", "1", source, tmp], { stdio: "inherit" });
3778
- const cfgFile = join(tmp, "oats-config.yaml");
3779
- if (!existsSync(cfgFile)) fail("E_TEMPLATE_SOURCE", `template repo has no oats-config.yaml on its default branch: ${source}`);
3780
- body = readFileSync(cfgFile, "utf8");
3781
- const commit = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
3782
- provenance = `${source}@${commit.slice(0, 12)}`;
3783
- } finally { rmSync(tmp, { recursive: true, force: true }); }
2107
+ const text = readFileSync(checkout.file, "utf8");
2108
+ let doc;
2109
+ try { doc = YAML.parseDocument(text, { keepSourceTokens: true }); } catch (e) { return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${e.message}`, { path: checkout.file }); }
2110
+ if (doc.errors?.length) return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${doc.errors.map((e) => e.message).join("; ")}`, { path: checkout.file });
2111
+ const root = doc.contents;
2112
+ if (!YAML.isMap(root)) return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: top level must be a mapping`, { path: checkout.file, problems: [{ path: "", message: "top level must be a mapping" }] });
2113
+ const packagesNode = root.get("packages", true);
2114
+ if (packagesNode !== undefined && packagesNode !== null && !(YAML.isScalar(packagesNode) && packagesNode.value === null) && !YAML.isMap(packagesNode)) {
2115
+ return bail("E_WORKSPACE_SCHEMA", `${shortPath(checkout.file)}: packages: must be a mapping of <package-id>: <version>, found ${YAML.isSeq(packagesNode) ? "a sequence" : JSON.stringify(packagesNode.toJSON?.() ?? String(packagesNode))}`, { path: checkout.file, problems: [{ path: "/packages", message: "must be an object" }] });
2116
+ }
2117
+ const previous = YAML.isMap(packagesNode) ? packagesNode.toJSON() : null;
2118
+ const had = previous && Object.hasOwn(previous, id) ? previous[id] : undefined;
2119
+ if (sub === "add") {
2120
+ if (!YAML.isMap(packagesNode)) root.set("packages", doc.createNode({ [id]: value }));
2121
+ else packagesNode.set(id, value);
3784
2122
  } else {
3785
- // Replacer FUNCTION, not a replacement string: `$&`, `$'`, `` $` `` and
3786
- // `$1` are substitution syntax in String.replace, and a home directory may
3787
- // legally contain them.
3788
- const path = resolve(source.replace(/^~\//, () => `${homedir()}/`));
3789
- if (!existsSync(path)) fail("E_TEMPLATE_SOURCE", `template config not found: ${path}`);
3790
- body = readFileSync(path, "utf8");
3791
- provenance = path;
3792
- }
3793
- // Snapshot: strip template-registry keys that make no sense in the seeded config.
3794
- const lines = body.replace(/\n*$/, "\n").split("\n");
3795
- const scaffoldName = scaffoldConfigName(dir);
3796
- const out = []; let skipping = false;
3797
- for (const line of lines) {
3798
- if (/^templates:\s*$/.test(line)) { skipping = true; continue; }
3799
- if (skipping) { if (/^\S/.test(line) && line.trim()) skipping = false; else continue; }
3800
- // Replacer FUNCTION, not a replacement string — this is a WRITE, so a
3801
- // directory named `x$&y` would otherwise persist a corrupted `name:` line
3802
- // (`name: xname: template-namey`).
3803
- out.push(line.replace(/^name:.*$/, () => `name: ${scaffoldName}`));
3804
- }
3805
- return `# template: ${provenance} (snapshot — later template edits do not propagate)\n${out.join("\n").replace(/\n*$/, "\n")}`;
2123
+ if (had === undefined) return bail("E_PACKAGE_MISSING", `packages.${id} is not in ${shortPath(checkout.file)}`, { id, path: checkout.file });
2124
+ root.get("packages").delete(id);
2125
+ if (root.get("packages")?.items?.length === 0) root.delete("packages");
2126
+ }
2127
+ const next = doc.toString();
2128
+ const problems = validateWorkspace(YAML.parse(next), { remote: remoteModule });
2129
+ if (problems.length) return bail("E_WORKSPACE_SCHEMA", `${shortPath(checkout.file)} would be invalid: ${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, { path: checkout.file, problems });
2130
+ writeFileAtomic(checkout.file, next);
2131
+ const receipt = { action: sub, id, value: value ?? null, previous: had ?? null, edited: true, file: checkout.file };
2132
+ if (JSON_MODE) { jsonOk(receipt); return; }
2133
+ console.log(sub === "add"
2134
+ ? `${had === undefined ? "Added" : `Changed (${had} →)`} packages.${id}: ${value} in ${shortPath(checkout.file)}. Commit it, then \`oats sync\` (the lock resolves the version to a commit and asks approval once).`
2135
+ : `Removed packages.${id} (${had}) from ${shortPath(checkout.file)}. Commit it, then \`oats sync\`.`);
3806
2136
  }
3807
2137
 
3808
- function init() {
3809
- const raw = args.includes("--raw");
3810
- const dir = dirFlag();
3811
- const file = join(dir, "oats-config.yaml");
3812
- const pkgSrc = flag("package");
3813
- // Every init form — classic, --template and --package — reports through the
3814
- // SAME one-envelope JSON boundary; nothing here may print two documents.
3815
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3816
- const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
3817
- if (existsSync(file)) bail("E_CONFIG_EXISTS", `${shortPath(file)} already exists — edit it or use \`oats use\``);
3818
- // The scaffolded `name:` value is FILESYSTEM input. Refuse it up front, before
3819
- // any init form mutates anything — a basename that cannot stay one YAML scalar
3820
- // must abort the run, not be discovered halfway through a transaction.
3821
- try { scaffoldConfigName(dir); }
3822
- catch (e) { bail(e.code, e.message); return; }
3823
-
3824
- if (pkgSrc && pkgSrc !== true) { initPackage(pkgSrc, dir, file); return; }
3825
- if (pkgSrc === true) { bail("E_USAGE", "--package needs a package id, local path, or git URL"); return; }
3826
-
3827
- const template = flag("template");
3828
- if (template && template !== true) {
3829
- let text;
3830
- try { text = loadTemplateConfig(template, dir); }
3831
- catch (e) { bail(e.code || "E_TEMPLATE_SOURCE", e.message); return; }
3832
- // Seeding is a transaction too. A template can carry keys this kernel
3833
- // refuses, or lock entries that will not restore; either way the config this
3834
- // run wrote must not be left behind for the next command to trip over, and
3835
- // the failure must be a typed error rather than an uncaught stack.
3836
- let journal;
3837
- try { journal = beginRunJournal(dir); }
3838
- catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
3839
- let activated = [];
3840
- try {
3841
- writeFileSync(file, text);
3842
- note(`Created ${shortPath(file)} (${levelOf(dir)} level) from template ${template}`);
3843
- // The GATE is that the kernel can read this config: a template carrying a
3844
- // retired key or a broken shape is a broken template, and leaving it
3845
- // behind would break every later command at this scope.
3846
- configChain(dir);
3847
- restore(dir);
3848
- // Activation is NOT a gate. A template's whole point is to seed policy you
3849
- // then acquire — a capability it activates but nothing supplies yet is the
3850
- // expected state right after seeding, not a reason to refuse the config.
3851
- try { activated = resolveOatsConfig(dir).capabilities.map((c) => ({ capability: c.id, layer: c.layer || null })); }
3852
- catch (e) { note(`NOTE: ${shortPath(file)} does not resolve yet — ${e.message}. Acquire what it activates (\`oats install <source>\`), then re-check with \`oats doctor\`.`); }
3853
- journal.finalize();
3854
- } catch (e) {
3855
- const report = journal.rollback();
3856
- const detail = `${shortPath(file)} could not be seeded from template ${template}: ${e.message}`;
3857
- bail(e.code || "E_TEMPLATE_UNUSABLE", report.complete ? detail : `${detail} — ${report.summary}`);
3858
- return;
3859
- }
3860
- if (JSON_MODE) { jsonOk({ file, level: levelOf(dir), raw, adopted: false, template, acquired: [], activated, requirements: [] }); return; }
3861
- offerTmuxMouseScrolling();
3862
- return;
3863
- }
3864
- if (template === true) { bail("E_USAGE", "--template needs a name, local config path, or git URL"); return; }
3865
-
3866
- // Per-layer overrides: --knowledge oats.okf, --messaging none, --tasks oats.jira …
3867
- const overrides = {};
3868
- const market = marketplaceCapabilities();
3869
- // Own-scope manifests are read DIRECTLY: no oats-config.yaml exists here yet,
3870
- // so the config-chain walk cannot see this scope's own store, and a
3871
- // capability already installed here would look unknown.
3872
- // Object.assign onto a null prototype, never an object spread: `{ ...map }`
3873
- // re-plainifies the null-prototype sources, and `mans[v]` is then indexed with
3874
- // a `--<layer>` value the operator typed — `--knowledge constructor` would
3875
- // read Object.prototype.constructor as a manifest and report a layer mismatch
3876
- // for a capability that does not exist.
3877
- const mans = Object.assign(Object.create(null), market, capabilityManifests(dir), ownScopeCapabilityManifests(dir));
3878
- for (const layer of LAYERS) {
3879
- const v = flag(layer);
3880
- if (v === undefined) continue;
3881
- if (v === true || String(v).startsWith("--")) bail("E_USAGE", `--${layer} needs a canonical capability ID or "none"`);
3882
- if (v !== "none") {
3883
- // Known locally: its declared layer is checkable right now, before any
3884
- // mutation. Otherwise the official catalog may still supply it, and the
3885
- // layer is verified against the MATERIALIZED manifest after acquisition —
3886
- // inside the run transaction, so a disagreement rolls the whole run back.
3887
- if (mans[v]) {
3888
- if (mans[v].layer !== layer) bail("E_LAYER_MISMATCH", `capability "${v}" declares layer "${mans[v].layer || "none"}", not "${layer}"`);
3889
- } else if (!officialCapabilityPackage(v).available) {
3890
- bail("E_UNKNOWN_CAPABILITY", `unknown capability "${v}" for --${layer} — it is not acquired at ${shortPath(dir)}, not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it (catalog: ${Object.keys(officialPackageCatalog()).join(", ") || "empty"})`);
3891
- }
3892
- }
3893
- overrides[layer] = v;
3894
- }
3895
-
3896
- const defaults = raw
3897
- ? { knowledge: "none", messaging: "none", tasks: "none" }
3898
- : { knowledge: "oats.okf", messaging: "oats.aweb", tasks: undefined };
3899
- let layers = { ...defaults, ...overrides };
3900
-
3901
- // Interactive TTY with no explicit layer flags: present each default and ask.
3902
- // Non-interactive contexts (agents, CI) keep flags-or-silent-defaults — never hang.
3903
- if (!raw && !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY && !Object.keys(overrides).length) {
3904
- const byLayer = (l) => Object.values(mans).filter((m) => m.layer === l).map((m) => m.capability);
3905
- console.log("Fundamental layers for this scope — Enter keeps the default, or type a capability id / \"none\":");
3906
- const ask = (prompt) => {
3907
- process.stdout.write(prompt);
3908
- const buffer = Buffer.alloc(256);
3909
- let length = 0;
3910
- try { length = readSync(process.stdin.fd, buffer, 0, buffer.length); } catch { /* EOF */ }
3911
- return buffer.subarray(0, length).toString("utf8").trim();
3912
- };
3913
- for (const layer of LAYERS) {
3914
- const options = byLayer(layer);
3915
- const def = layers[layer] || "none";
3916
- while (true) {
3917
- const answer = ask(` ${layer.padEnd(10)} [${def}] (options: ${[...options, "none"].join(", ")}): `);
3918
- if (!answer) break;
3919
- if (answer === "none" || options.includes(answer)) { layers[layer] = answer; break; }
3920
- console.log(` unknown "${answer}" — pick one of: ${[...options, "none"].join(", ")}`);
3921
- }
3922
- }
3923
- if ((layers.messaging || "none") !== "none") console.log(" (messaging via aweb: after init, run `oats aweb setup` for guided onboarding)");
3924
- }
3925
- // ---- Everything below MUTATES. It is ONE run-level transaction: the config
3926
- // file, the lock, the flat capability artifacts, the capability .gitignore
3927
- // and any `.agents` anchor this run creates roll back together. A capability
3928
- // that was already installed at this scope before the run is restored
3929
- // byte-identically — this run only ever undoes its own changes. ----
3930
- let journal;
3931
- try { journal = beginRunJournal(dir); }
3932
- catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
3933
- const abort = (e, code) => {
3934
- const report = journal.rollback();
3935
- bail(code || e.code || "E_INIT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
3936
- };
3937
-
3938
- const acquisitions = [];
3939
- let resolved;
3940
- const lines = [
3941
- `name: ${scaffoldConfigName(dir)}`,
3942
- "",
3943
- "# ── Agent types (families) — declared here by name (or via `oats type add`);",
3944
- "# each soul opts in via `type: <name>` in its soul.yaml. Capability entries can target them.",
3945
- "# agent-types:",
3946
- "# reviewers:",
3947
- "# description: Agents that review changes",
3948
- "",
3949
- "capabilities:",
3950
- " # Fundamental layers — exclusive slots; a capability entry or an explicit none.",
3951
- " layers:",
3952
- ];
3953
- try {
3954
- for (const layer of LAYERS) {
3955
- const selected = layers[layer];
3956
- if (!selected) { lines.push(` # ${layer}: (unset — inherits from outer config scopes; set an entry or "none")`); continue; }
3957
- if (selected === "none") { lines.push(` ${layer}: none`); continue; }
3958
- // Already here (own scope first — see above), or acquired now.
3959
- const manifest = ownScopeCapabilityManifest(dir, selected)
3960
- || capabilityManifest(selected, dir)
3961
- || acquireLayerCapability(dir, selected, layer, acquisitions, note);
3962
- lines.push(` ${layer}:`);
3963
- lines.push(` capability: ${manifest.capability}`);
3964
- if (String(manifest._origin).startsWith("installed:")) { lines.push(" from: installed"); lines.push(` # injection-override: .agents/injections/capabilities/${manifest.capability}.md`); }
3965
- else if (String(manifest._origin).startsWith("owned:")) { lines.push(" from: owned"); lines.push(` # injection edited at source: .agents/capabilities/owned/${manifest.capability}/injects/`); }
3966
- }
3967
- lines.push(
3968
- " # Additive capabilities — non-exclusive; target global, agent-types, or souls.",
3969
- " # additive:",
3970
- " # <capability-id>:",
3971
- " # from: installed",
3972
- " # global: true",
3973
- " # # injection-override: .agents/injections/capabilities/<capability-id>.md",
3974
- "",
3975
- "# ── Work modes — optional per-mode env bootstrap.",
3976
- "# `setup:` runs inside each NEW worktree right after `git worktree add` — use it",
3977
- "# for env setup scripts (installs, .env copying, direnv, mise, etc.).",
3978
- "# The path is relative to this config's directory.",
3979
- "work-modes:",
3980
- " worktree:",
3981
- " # setup: scripts/setup-worktree.sh",
3982
- "",
3983
- "# ── OATS defaults — the framework's baseline instruction block.",
3984
- "oats:",
3985
- " # injection-override: .agents/injections/oats-defaults/oats.md",
3986
- );
3987
- writeFileSync(file, lines.join("\n") + "\n");
3988
- // Resolve INSIDE the transaction: a config this run wrote that cannot
3989
- // resolve is a broken scope, so it fails the init and rolls back rather
3990
- // than being left behind for the next command to trip over.
3991
- resolved = resolveOatsConfig(dir);
3992
- journal.finalize();
3993
- } catch (e) { abort(e); return; }
3994
-
3995
- note(`Created ${shortPath(file)} (${levelOf(dir)} level${raw ? ", raw" : ""})`);
3996
- // Acquisition is not activation, not executable trust, and not requirement
3997
- // consent — say so per acquisition rather than implying the layer is ready.
3998
- for (const a of acquisitions) {
3999
- if (!a.executableSurface.length) continue;
4000
- note(`Executable surfaces from ${a.package || "the marketplace"} are blocked until trusted: ${a.executableSurface.map((c) => `oats trust ${c}`).join("; ")}`);
4001
- }
4002
-
4003
- const r = resolved;
4004
- const activated = [];
4005
- for (const cap of r.capabilities) {
4006
- activated.push({ capability: cap.id, layer: cap.layer || null });
4007
- note(`Activated: ${cap.id}${cap.layer ? ` → ${cap.layer}` : ""}`);
4008
- for (const miss of cap.missingRequires) note(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
4009
- }
4010
- if (JSON_MODE) {
4011
- jsonOk({
4012
- file, level: levelOf(dir), raw, adopted: false,
4013
- layers: Object.fromEntries(LAYERS.map((l) => [l, layers[l] ?? null])),
4014
- acquired: acquisitions, activated,
4015
- // Same facts the human run prints, in the same run: who asked, why, and
4016
- // the ONE copyable command that consents to installing it. Init never
4017
- // runs it — reporting a requirement and acting on it are separate steps,
4018
- // and an agent reading this envelope must be able to tell them apart.
4019
- requirements: r.capabilities.flatMap((c) => c.missingRequires.map((m) => ({
4020
- capability: c.id, command: m.command, why: m.why || null, install: m.install || null,
4021
- consentCommand: `oats install --accept-requirement ${m.command} --dir ${shellQuote(dir)}`,
4022
- }))),
4023
- });
4024
- return;
4025
- }
4026
- offerTmuxMouseScrolling();
2138
+ /** `oats workspace status [--dir] [--json]` — contract §6. */
2139
+ async function workspaceCmd() {
2140
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2141
+ if (args[1] !== "status") return bail("E_USAGE", "usage: oats workspace status [--dir <d>] [--json]");
2142
+ const ctx = workspaceContext(bail);
2143
+ const discovery = await discoverForCli(ctx, bail);
2144
+ let lock;
2145
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2146
+ const members = memberRows(discovery);
2147
+ const packages = packageRows(lock);
2148
+ // Standalone (decision 10): there is no workspace file — the declared packages are the
2149
+ // kernel's own default (decision 25) and there are no teams.
2150
+ const standalone = discovery.standalone === true;
2151
+ const declared = Object.keys(standalone ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {})).sort();
2152
+ const locked = new Set(packages.map((p) => p.id));
2153
+ const unsynced = declared.filter((id) => !locked.has(id));
2154
+ const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
2155
+ const approval = { approved: packages.filter((p) => p.approved).map((p) => p.id), needed: packages.filter((p) => !p.approved).map((p) => p.id) };
2156
+ const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, approval, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems };
2157
+ if (JSON_MODE) { jsonOk(result); return; }
2158
+ console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
2159
+ if (standalone) console.log(` (standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)\n`);
2160
+ console.log("Members:");
2161
+ printTable(["member", "status", "commit", "team", "souls", "capabilities", "publishes"], members.map((m) => [m.name, m.status, short(m.commit), m.team ?? "—", m.souls.join(",") || "—", m.capabilities.join(",") || "—", m.publishes ? `${m.publishes.package} v${m.publishes.version ?? "?"}` : "—"]));
2162
+ for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
2163
+ console.log("\nPackages:");
2164
+ if (!packages.length) console.log(unsynced.length ? ` (none locked yet — \`oats sync\` resolves ${unsynced.join(", ")})` : " (none)");
2165
+ else printTable(["package", "version", "source", "commit", "approval", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.approved ? `approved ${p.approved.at.slice(0, 10)}` : "NEEDED", p.capabilities.join(",")]));
2166
+ if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
2167
+ if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
2168
+ 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(" ")}`);
2169
+ if (discovery.problems.length) { console.log("\nProblems:"); for (const p of discovery.problems) console.log(` ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`); }
2170
+ }
2171
+
2172
+ /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
2173
+ async function itemsCmd(kind) {
2174
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2175
+ const ctx = workspaceContext(bail);
2176
+ const discovery = await discoverForCli(ctx, bail);
2177
+ let lock;
2178
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2179
+ const items = workspaceItems(discovery, lock)[kind];
2180
+ const standalone = discovery.standalone === true;
2181
+ if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
2182
+ console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — standalone: the workspace of ${memberLabel(discovery.key)} cannot be read` : ""}\n`);
2183
+ if (!items.length) console.log(" (none)");
2184
+ else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
2185
+ else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
2186
+ const unsynced = Object.keys(discovery.workspace?.packages || {}).filter((id) => !lock.packages[id]);
2187
+ if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
4027
2188
  }
4028
2189
 
4029
- /** Acquire the capability backing one fundamental layer at classic-init time.
4030
- *
4031
- * Catalog-first: when an official package supplies the capability it comes
4032
- * through the package engine — flat materialization, a capability-materialization
4033
- * lock, and NO implicit executable trust. The legacy standalone-capability route
4034
- * survives only for marketplace capabilities the official catalog cannot supply
4035
- * today, and it is the only branch that still writes a v1 lock.
4036
- *
4037
- * Throws on every failure: the caller holds the run journal, and exiting here
4038
- * would strand its backup. */
4039
- function acquireLayerCapability(dir, capId, layer, acquired, note) {
4040
- const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
4041
- const official = officialCapabilityPackage(capId);
4042
- if (official.available) {
4043
- const acq = acquirePackage(dir, official.package);
4044
- if (!acq.capabilities.some((c) => c.capability === capId)) {
4045
- fail("E_LAYER_NOT_EXPORTED", `package ${official.package} does not export capability "${capId}" — it exports ${acq.capabilities.map((c) => c.capability).join(", ") || "nothing"}`);
4046
- }
4047
- // The layer is verified against the manifest actually WRITTEN TO DISK, never
4048
- // against the marketplace copy or the catalog's word for it.
4049
- const manifest = ownScopeCapabilityManifest(dir, capId);
4050
- if (!manifest) fail("E_LAYER_UNREADABLE", `capability "${capId}" was materialized but its manifest under ${shortPath(installedCapabilityDir(dir, capId))} is unreadable`);
4051
- if (manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
4052
- const executableSurface = acq.capabilities
4053
- .filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
4054
- .map((c) => c.capability);
4055
- acquired.push({
4056
- layer, capability: capId, route: "package", package: official.package, via: official.via,
4057
- packages: acq.installed.map((p) => ({ package: p.package, version: p.version || null, commit: p.commit || null })),
4058
- lockFile: acq.lockFile, trusted: false, executableSurface,
4059
- });
4060
- note(`Acquired package ${official.package} for the ${layer} layer → ${capId} (${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")}) → ${shortPath(acq.lockFile)}`);
4061
- return { ...manifest, _origin: `installed:${dir}` };
2190
+ // ---------- roster: status / spawn / retire / create ----------
2191
+ /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
2192
+ * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
2193
+ * the members' current state (and package modules to the lock). Offline → { unreachable }.
2194
+ * Returns null when the deployment is not a workspace deployment (no oats-local.yaml). */
2195
+ async function statusDrift(data) {
2196
+ let ctx;
2197
+ try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
2198
+ const hasModules = data.some((a) => (a.instances || []).some((i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length));
2199
+ if (!hasModules) return { drift: new Map(), unreachable: null };
2200
+ const deploymentDir = dirname(ctx.path);
2201
+ let lock = null;
2202
+ try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
2203
+ let discovery;
2204
+ // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
2205
+ // instance is computed against its member's current state, not reported "unreachable".
2206
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
2207
+ catch (e) {
2208
+ const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
2209
+ return { drift: new Map(), unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
4062
2210
  }
4063
- const market = marketplaceCapabilities();
4064
- if (!market[capId]) {
4065
- fail("E_UNKNOWN_CAPABILITY", `capability "${capId}" is not acquired at ${shortPath(dir)}, is not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it`);
2211
+ const { driftOf } = await import("../lib/materialize.mjs");
2212
+ const drift = new Map();
2213
+ for (const a of data) for (const i of a.instances || []) {
2214
+ if (!i.modules || typeof i.modules !== "object" || !Object.keys(i.modules).length) continue;
2215
+ try { drift.set(i.home ?? `${a.name}/${i.instance}`, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ }
4066
2216
  }
4067
- // Legacy route: kernel-bundled marketplace capabilities predate the official
4068
- // packages, ship with the kernel already installed, and keep their v1 lock and
4069
- // acquisition-time trust until the catalog covers them.
4070
- const r = acquireCapability(dir, capId);
4071
- try {
4072
- writeCapabilityLock(dir, r.manifest.capability, {
4073
- source: r.source, version: r.manifest.version || null, integrity: r.integrity, trustedExecutables: true,
4074
- });
4075
- } catch (e) { rmSync(r.dest, { recursive: true, force: true }); throw e; }
4076
- if (r.manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${r.manifest.layer || "none"}", not "${layer}"`);
4077
- acquired.push({ layer, capability: capId, route: "marketplace", package: null, via: "marketplace", packages: [], lockFile: join(dir, OATS_LOCK_FILE), trusted: true, executableSurface: [] });
4078
- note(`Acquired ${r.manifest.capability}@${r.manifest.version} from the marketplace → ${shortPath(r.dest)}`);
4079
- return { ...r.manifest, _origin: `installed:${dir}` };
2217
+ return { drift, unreachable: null };
4080
2218
  }
4081
-
4082
- /** Capability manifests physically present at THIS scope's own store.
4083
- *
4084
- * `capabilityManifests` walks the config chain, so during `oats init` — when no
4085
- * oats-config.yaml exists at the target scope yet — this scope is not a level and
4086
- * its own installed/ and owned/ capabilities are invisible. Init reads them
4087
- * directly instead, which is also what makes a same-run acquisition visible to
4088
- * the rest of the run. */
4089
- function ownScopeCapabilityManifests(dir) {
4090
- // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
4091
- // braces on the write side (store directory names are identity-validated at
4092
- // acquisition); it matters on the read side, where `oats init` indexes this
4093
- // map with a `--<layer>` flag value the operator typed.
4094
- const out = Object.create(null);
4095
- for (const [sub, origin] of [[installedCapabilitiesDir(dir), "installed"], [ownedCapabilitiesDir(dir), "owned"]]) {
4096
- if (!existsSync(sub)) continue;
4097
- let entries;
4098
- try { entries = readdirSync(sub, { withFileTypes: true }); } catch { continue; }
4099
- for (const e of entries) {
4100
- // Dot-prefixed entries are transaction staging, never installed content.
4101
- if (!e.isDirectory() || e.name.startsWith(".")) continue;
4102
- let raw;
4103
- try { raw = JSON.parse(readFileSync(join(sub, e.name, "oats.json"), "utf8")); } catch { continue; }
4104
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
4105
- // Strip BEFORE the spread. `_dir` and `_origin` are reassigned just after
4106
- // it, but every OTHER annotation in the namespace — `_capabilityLock`,
4107
- // `_package`, `_soulDir` … — would flow straight out of an
4108
- // artifact-controlled document. That is the exact shape the kernel's own
4109
- // manifest reader was fixed for, and this map is merged OVER that
4110
- // stripped one, so leaving it raw kept the forgery carrier alive.
4111
- const m = stripInternalAnnotations(raw);
4112
- if (typeof m.capability === "string") out[m.capability] = { ...m, _dir: join(sub, e.name), _origin: `${origin}:${dir}` };
4113
- }
4114
- }
4115
- return out;
2219
+ /** One `modules:` line per module. */
2220
+ function driftLine(row) {
2221
+ const from = row.from || {};
2222
+ const origin = from.kind === "package" ? `package ${from.package} v${from.version}` : memberLabel(row.recorded?.repoKey ?? from.repoKey ?? "?");
2223
+ const base = `modules: ${row.module} from ${origin} @ ${short7(row.recorded?.commit)}`;
2224
+ if (row.status === "moved") return `${base} [${from.kind === "package" ? "package" : "member"} moved since (now @ ${short7(row.current?.commit)})]`;
2225
+ if (row.status === "missing") return `${base} [${row.reason === "capability-absent" ? "capability no longer present" : row.reason === "package-absent" ? "package no longer locked" : `member ${row.reason || "unconfirmed"}`}]`;
2226
+ return base;
4116
2227
  }
4117
- const ownScopeCapabilityManifest = (dir, capId) => ownScopeCapabilityManifests(dir)[capId];
2228
+ const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
4118
2229
 
4119
- // ---------- roster: status / spawn / retire / create ----------
4120
- function status() {
2230
+ async function status() {
4121
2231
  if (args.includes("--team")) return statusTeam();
4122
- const root = ensureRoot(dirFlag());
2232
+ let root;
2233
+ try { root = ensureRoot(dirFlag()); }
2234
+ catch (e) { if (e?.code === "E_NO_DEPLOYMENT") { if (JSON_MODE) jsonFail("E_NO_DEPLOYMENT", e.message, e.details ?? e.provenance); die(e.message); } throw e; }
4123
2235
  const data = listInstances(root);
4124
- if (args.includes("--json")) { console.log(JSON.stringify({ root, agents: data }, null, 2)); return; }
2236
+ const ws = await statusDrift(data);
2237
+ const verbose = args.includes("--verbose");
2238
+ if (args.includes("--json")) {
2239
+ if (ws) for (const a of data) for (const i of a.instances || []) { const rows = ws.drift.get(i.home ?? `${a.name}/${i.instance}`); 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 } : {}) })); }
2240
+ console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
2241
+ }
4125
2242
  console.log(`oats status — agents root ${shortPath(root)}\n`);
2243
+ if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
4126
2244
  if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
4127
2245
  for (const a of data) {
4128
2246
  console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
4129
2247
  if (a.description) console.log(` ${a.description}`);
4130
2248
  for (const i of a.instances) {
4131
2249
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2250
+ const rows = ws?.drift.get(i.home ?? `${a.name}/${i.instance}`) || [];
2251
+ for (const r of rows) if (verbose || r.status !== "current") console.log(` ${driftLine(r)}`);
4132
2252
  }
4133
2253
  for (const f of a.retireFailures || []) {
4134
2254
  console.log(` ! deferred retirement of ${f.instance} FAILED${f.completedAt ? ` at ${f.completedAt}` : ""}: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
@@ -4159,7 +2279,7 @@ function statusTeam() {
4159
2279
  }
4160
2280
  }
4161
2281
 
4162
- function spawnCmd() {
2282
+ async function spawnCmd() {
4163
2283
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
4164
2284
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
4165
2285
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
@@ -4195,6 +2315,52 @@ function spawnCmd() {
4195
2315
  root = hit.root;
4196
2316
  }
4197
2317
  let agent = findAgent(root, name);
2318
+ // Workspace model: with an oats-local.yaml the soul is ALWAYS discovered over the
2319
+ // remotes and resolved (member = latest state, package = locked+approved) — never
2320
+ // "whatever <agents-root>/<name>/soul/ happens to hold": that copy is a per-commit
2321
+ // cache (ensureWorkspaceSoul refreshes it when the member moved), so a second
2322
+ // spawn sees the member's CURRENT soul, not the first spawn's. A preview runs the
2323
+ // same read-only discovery+resolution; the fetched soul copy it may leave under
2324
+ // <agents-root>/<name>/soul/ is not an instance (reported as soulFetched).
2325
+ const providerPairs = [];
2326
+ for (let i = 0; i < args.length; i++) if (args[i] === "--provider") { if (!args[i + 1] || !args[i + 2]) bail("E_BAD_ARGS", "--provider needs <capability> <key>=<value>"); providerPairs.push([args[i + 1], args[i + 2]]); i += 2; }
2327
+ let wsPrepared, soulFetched = false, wsSoulUnknown = null;
2328
+ let hasLocal = true;
2329
+ {
2330
+ try { loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") hasLocal = false; else bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
2331
+ if (hasLocal) {
2332
+ let discovery = null;
2333
+ try {
2334
+ const { prepareInstance, ensureWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
2335
+ const remoteOptions = remoteOptionsFromEnv();
2336
+ const { local } = loadLocal(dirFlag());
2337
+ discovery = await discoverOrStandalone(local, { remoteOptions });
2338
+ wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
2339
+ const soulName = wsPrepared.soulEntry.name;
2340
+ const stampFile = join(root, soulName, ".oats-soul-source.json");
2341
+ const stampBefore = (() => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } })();
2342
+ const soulDir = await ensureWorkspaceSoul(wsPrepared, root);
2343
+ soulFetched = !stampBefore || stampBefore.commit !== wsPrepared.soulEntry.commit || stampBefore.repoKey !== wsPrepared.soulEntry.repoKey;
2344
+ if (!agent || soulFetched || agent._dir !== dirname(soulDir)) agent = findAgent(root, soulName);
2345
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
2346
+ note(`(workspace soul: "${name}" from ${wsPrepared.soulEntry.repoKey} @ ${String(wsPrepared.soulEntry.commit).slice(0, 12)}${wsPrepared.soulEntry.team ? `, team ${wsPrepared.soulEntry.team}` : ""}${soulFetched ? "; soul source fetched" : ""})`);
2347
+ } catch (e) {
2348
+ // Standalone (decisions 10/25): the ONLY package request is the kernel's own
2349
+ // default; when the catalog cannot name it, say so instead of "add it to packages:"
2350
+ // (there is no workspace file to add it to).
2351
+ if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
2352
+ let file = process.env.OATS_PACKAGE_CATALOG || null; try { file = describeOfficialCatalog().catalog.file; } catch { /* keep the env value */ }
2353
+ bail(e.code, `${e.details?.capability ?? "oats.core"}: the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}) — standalone spawns resolve only the kernel's default package from the catalog`, { ...(e.details ?? {}), standalone: true, reason: "no-catalog", catalog: file });
2354
+ }
2355
+ // Not a workspace soul: a capability-defined agent (a module's `agents:`
2356
+ // soul, resolved below from a materialized copy) or a local-only soul
2357
+ // (--instructions-file/--def-file) may still answer to this name.
2358
+ if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
2359
+ else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
2360
+ else throw e;
2361
+ }
2362
+ } else if (providerPairs.length) bail("E_BAD_ARGS", "--provider needs a workspace deployment (oats-local.yaml); this directory has none");
2363
+ }
4198
2364
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
4199
2365
  if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
4200
2366
  if (isPreview && (flag("instructions-file") !== undefined || flag("def-file") !== undefined)) bail("E_BAD_ARGS", "--preview does not take --instructions-file/--def-file: a preview never writes a soul");
@@ -4208,6 +2374,33 @@ function spawnCmd() {
4208
2374
  note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
4209
2375
  }
4210
2376
  }
2377
+ if (!agent && !instrFile && !defFile && hasLocal) {
2378
+ // Workspace model: the agent is declared by a capability some INSTANCE already
2379
+ // materialized (the --parent home first, then any home under this root) —
2380
+ // OKF's memory-harvest worker spawned by a knowledge source, for example.
2381
+ const anchorName = flag("parent") || flag("relative-to");
2382
+ const anchorHome = anchorName ? (findInstanceHome(root, String(anchorName)) ?? null) : null;
2383
+ let modAgent;
2384
+ try { modAgent = findModuleCapabilityAgent(root, name, { anchorHome }); }
2385
+ catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
2386
+ if (modAgent) {
2387
+ agent = modAgent;
2388
+ note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home locally)`);
2389
+ } else {
2390
+ // No instance carries it: resolve from the deployment's LOCK — an approved
2391
+ // package whose capability declares agents/<name> is fetched into the
2392
+ // deployment's module store and read from there.
2393
+ try {
2394
+ const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
2395
+ const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
2396
+ if (hit) {
2397
+ agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
2398
+ if (agent) note(`(capability agent: "${name}" from ${hit.capability} — package ${hit.package} v${hit.version}, fetched to ${shortPath(hit.dir)} — fresh soul, instances home locally)`);
2399
+ }
2400
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2401
+ }
2402
+ }
2403
+ if (!agent && wsSoulUnknown && !instrFile && !defFile) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
4211
2404
  if (!agent && !instrFile && !defFile) {
4212
2405
  // Cross-repo lookup: the soul may live in a sibling repo of the team scope.
4213
2406
  // Unique match wins; the instance homes with its owning repo's agents root.
@@ -4303,11 +2496,26 @@ function spawnCmd() {
4303
2496
  }
4304
2497
  if (wake && (typeof wake.message !== "string" || !wake.message.trim())) bail("E_SCHEDULE_INVALID", "wake message: non-empty text is required");
4305
2498
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2499
+ // Workspace model: when this deployment has an oats-local.yaml, the soul's
2500
+ // capabilities are resolved over the workspace's remotes (member = latest,
2501
+ // package = locked+approved) and copied whole into the new home. Without one
2502
+ // (a bare agents root, tests) the classic soul-directory spawn proceeds.
2503
+ let prepared;
2504
+ if (wsPrepared) {
2505
+ try {
2506
+ const { toCapabilityRows, modulesPreview } = await import("../lib/instance-resolution.mjs");
2507
+ prepared = wsPrepared;
2508
+ prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
2509
+ prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
2510
+ prepared.toCapabilityRows = toCapabilityRows;
2511
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2512
+ }
4306
2513
  let r;
4307
2514
  try {
4308
2515
  if (args.includes("--allow-child-spawns") && args.includes("--no-child-spawns")) bail("E_BAD_ARGS", "--allow-child-spawns and --no-child-spawns contradict");
4309
2516
  if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
4310
- r = spawnInstance(root, agent, {
2517
+ { const spawnOpts = {
2518
+ prepared,
4311
2519
  purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
4312
2520
  ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
4313
2521
  // Directory execution uses deployment configuration, not an ambient Git
@@ -4326,8 +2534,16 @@ function spawnCmd() {
4326
2534
  ...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
4327
2535
  // K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
4328
2536
  ...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
4329
- });
4330
- if (args.includes("--preview")) { if (JSON_MODE) { jsonOk(r); return; } console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created`); return; }
2537
+ };
2538
+ r = prepared ? await spawnInstanceAsync(root, agent, spawnOpts) : spawnInstance(root, agent, spawnOpts); }
2539
+ if (args.includes("--preview")) {
2540
+ // A workspace preview may have fetched the soul's SOURCE under <agents-root>/<name>/soul/
2541
+ // (a per-commit cache, not an instance): the result says so.
2542
+ if (prepared) r.soulFetched = soulFetched;
2543
+ if (JSON_MODE) { jsonOk(r); return; }
2544
+ console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? ` (the soul source was fetched to ${shortPath(join(root, r.agent, "soul"))} — a per-commit copy, not an instance)` : ""}`);
2545
+ return;
2546
+ }
4331
2547
  } catch (e) {
4332
2548
  // A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
4333
2549
  // (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
@@ -4545,7 +2761,11 @@ function scheduleCmd() {
4545
2761
  }
4546
2762
  default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
4547
2763
  }
4548
- } catch (e) { cmdFail(e.code || "E_SCHEDULE_FAILED", e.message); }
2764
+ } catch (e) {
2765
+ // K8b: typed refusal details travel (identity mismatch: key/declared; a refused file: its integrity source).
2766
+ const details = Object.fromEntries(["key", "declared", "source", "field"].filter((k) => e[k] !== undefined).map((k) => [k, e[k]]));
2767
+ if (JSON_MODE) jsonFail(e.code || "E_SCHEDULE_FAILED", e.message, Object.keys(details).length ? details : undefined); else die(e.message);
2768
+ }
4549
2769
  }
4550
2770
 
4551
2771
  async function sessionCmd() {
@@ -4600,143 +2820,141 @@ async function paneCmd() {
4600
2820
  die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
4601
2821
  }
4602
2822
 
4603
- function onboardCmd() {
4604
- const fail = (code, message, details) => JSON_MODE ? jsonFail(code, message, details) : die(message);
4605
- const values = new Map();
2823
+ /** `oats onboard [<dir>] --workspace <repo ref> [--json]` — workspace model v2 (decision 9).
2824
+ *
2825
+ * Realizes a workspace on this machine in the taught `<name>-workspace/` layout
2826
+ * (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
2827
+ * `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
2828
+ * instance homes), then runs exactly the `oats sync` path — discover over the
2829
+ * remotes, confirm membership, resolve `packages:`, approve (TTY) or list what
2830
+ * needs approval (exit 2), write `oats-lock.json`. Nothing is installed, no soul
2831
+ * is created, nothing is spawned, no `oats-config.yaml` is written: the member
2832
+ * clones and the setup expert are the operator's next steps, printed here. */
2833
+ async function onboardCmd() {
2834
+ const bail = (code, message, details) => (JSON_MODE ? jsonFail(code, message, details) : die(message));
2835
+ const usage = "usage: oats onboard [<dir>] --workspace <repo ref> [--json] (or --dir <dir>)";
2836
+ let positional, workspaceRef, dirValue;
4606
2837
  for (let i = 1; i < args.length; i++) {
4607
2838
  const arg = args[i];
4608
- if (["--json", "--force-existing"].includes(arg)) { values.set(arg.slice(2), true); continue; }
4609
- if (!["--dir", "--workspace"].includes(arg) || values.has(arg.slice(2)) || !args[i + 1] || args[i + 1].startsWith("--")) {
4610
- fail("E_BAD_ARGS", "usage: oats onboard [--dir <deployment>] [--workspace <git:source[@revision]>] [--force-existing] [--json]");
4611
- }
4612
- values.set(arg.slice(2), args[++i]);
4613
- }
4614
- dropAmbientRoot();
4615
- let deployment, root, acquired, created, configFile, configBefore, configWritten;
2839
+ if (arg === "--json") continue;
2840
+ if (arg === "--dir" || arg === "--workspace") {
2841
+ const value = args[i + 1];
2842
+ if (value === undefined || value.startsWith("--")) return bail("E_BAD_ARGS", `--${arg.slice(2)} needs a value\n${usage}`);
2843
+ if (arg === "--dir") { if (dirValue !== undefined) return bail("E_BAD_ARGS", usage); dirValue = value; }
2844
+ else { if (workspaceRef !== undefined) return bail("E_BAD_ARGS", usage); workspaceRef = value; }
2845
+ i++; continue;
2846
+ }
2847
+ if (arg.startsWith("--")) return bail("E_BAD_ARGS", `unknown flag ${arg}\n${usage}`);
2848
+ if (positional !== undefined) return bail("E_BAD_ARGS", usage);
2849
+ positional = arg;
2850
+ }
2851
+ if (positional !== undefined && dirValue !== undefined) return bail("E_BAD_ARGS", `give the deployment directory once, as <dir> or --dir\n${usage}`);
2852
+ if (!workspaceRef || !workspaceRef.trim()) return bail("E_BAD_ARGS", `--workspace <repo ref> is required (the repository hosting oats-workspace.yaml)\n${usage}`);
2853
+ workspaceRef = workspaceRef.trim();
2854
+ // The ref must be one lib/remote.mjs understands BEFORE anything is written.
2855
+ try { remoteModule.parseRepoRef(workspaceRef); }
2856
+ catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); }
2857
+
2858
+ const dir = resolve(positional ?? dirValue ?? process.cwd());
2859
+ const localFile = join(dir, "oats-local.yaml");
2860
+ // (1) Refuse to onboard twice: THIS directory's oats-local.yaml is the mark (an enclosing
2861
+ // deployment's file does not count — a nested directory is a different deployment).
2862
+ let existing = null;
2863
+ try { existing = lstatSync(localFile); } catch (e) { if (e.code !== "ENOENT") return bail("E_ONBOARD_FAILED", `cannot inspect ${localFile}: ${e.message}`); }
2864
+ if (existing) return bail("E_ALREADY_ONBOARDED", `${shortPath(localFile)} already exists — this directory realizes a workspace; run \`oats sync --dir ${shortPath(dir)}\` to refresh it`, { local: localFile, dir });
2865
+ if (existsSync(dir) && !lstatSync(dir).isDirectory()) return bail("E_ONBOARD_FAILED", `${shortPath(dir)} exists and is not a directory`, { dir });
2866
+
2867
+ // (2) The two files/dirs onboarding owns. Written atomically; rolled back if the sync
2868
+ // that follows cannot even read the workspace (a typo'd ref must not leave a
2869
+ // half-onboarded directory that looks finished).
2870
+ const created = [];
4616
2871
  try {
4617
- // Like create: an existing enclosing roster wins, otherwise bootstrap at
4618
- // the enclosing Git root or explicit directory. Canonicalize existing parents.
4619
- const requested = resolve(values.get("dir") || process.cwd()), missing = [];
4620
- let parent = requested;
4621
- while (!existsSync(parent)) { missing.unshift(basename(parent)); parent = dirname(parent); }
4622
- const start = join(realpathSync(parent), ...missing);
4623
- root = findRoot(start) || join(defaultRepo(start) || start, "agents");
4624
- deployment = dirname(root);
4625
- assertNoSymlinkedParents(deployment, root, "onboard agents root");
4626
- assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4627
- const assertSetupAbsent = () => {
4628
- 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)]) {
4629
- let present = false;
4630
- try { lstatSync(candidate); present = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
4631
- 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" });
4632
- }
4633
- };
4634
- assertSetupAbsent();
4635
- const agents = listAgents(root);
4636
- 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" });
4637
- if (findAgent(root, SETUP_EXPERT)) throw Object.assign(new Error("oats-setup-expert already exists; it will not be overwritten"), { code: "E_AGENT_EXISTS" });
4638
- const edition = loadSetupExpertEdition(values.get("workspace"));
4639
- for (const key of ["description", "runtime", "model"]) if (edition.declaration[key] !== undefined) assertSafeConfigValue(edition.declaration[key], `setup edition ${key}`);
4640
- const catalog = officialPackageCatalog();
4641
- // Catalog precedence: an explicit OATS_PACKAGE_CATALOG override, else the entry the WORKSPACE
4642
- // publishes at the edition's revision, else the kernel's bundled snapshot. The bundled copy lags
4643
- // every oats.framework release cut after this kernel's tag, and a second operator has no main
4644
- // checkout to point an override at (0.24.5; second-operator finding).
4645
- const bundledEntry = catalog["oats.framework"];
4646
- const entry = process.env.OATS_PACKAGE_CATALOG ? bundledEntry : (edition.catalogEntry ?? bundledEntry);
4647
- const catalogOrigin = process.env.OATS_PACKAGE_CATALOG ? "override" : edition.catalogEntry ? "workspace" : "bundled";
4648
- if (!Object.hasOwn(catalog, "oats.framework") || !entry?.url || !entry.ref
4649
- || SETUP_CAPABILITIES.some(id => { const m = officialCapabilityPackage(id); return !m.available || m.package !== "oats.framework" || m.migratedCapability !== id; })) {
4650
- throw Object.assign(new Error("official oats.framework with core/setup aliases and a published revision is required"), { code: "needs-configuration" });
4651
- }
4652
- const catalogSource = parsePortableSource(`git:${entry.url}@${entry.ref}#${entry.path ?? DEFAULT_PACKAGE_PATH}`);
4653
- const file = join(deployment, "oats-config.yaml");
4654
- if (existsSync(file) && !lstatSync(file).isFile()) throw Object.assign(new Error("onboard will not replace a non-regular deployment configuration"), { code: "E_CONFIG_BROKEN" });
4655
- const before = existsSync(file) ? readFileSync(file, "utf8") : null;
4656
- configFile = file; configBefore = before;
4657
- const caps = readCapabilitiesModel(file), previous = resolveOatsConfig(deployment, SETUP_EXPERT);
4658
- // Exclusions are for the NEW soul only. Never turn off an existing root's
4659
- // global provider/layer just to make bootstrap work under --force-existing.
4660
- for (const cap of previous.capabilities) {
4661
- if (SETUP_CAPABILITIES.includes(cap.id)) continue;
4662
- let target;
4663
- if (cap.layer) {
4664
- const existing = caps.layers[cap.layer];
4665
- 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" });
4666
- target = caps.layers[cap.layer] ||= { capability: cap.id };
4667
- } else target = caps.additive[cap.id] ||= {};
4668
- target.souls = { ...target.souls, [SETUP_EXPERT]: false };
4669
- }
4670
- if (before === null && !agents.length) for (const layer of LAYERS) caps.layers[layer] ??= "none";
4671
- for (const id of SETUP_CAPABILITIES) {
4672
- const target = caps.additive[id] ||= {};
4673
- if (target.from && target.from !== "installed") throw Object.assign(new Error(`${id} already selects another provenance; choose a fresh deployment`), { code: "needs-configuration" });
4674
- target.from = "installed"; target.souls = { ...target.souls, [SETUP_EXPERT]: true };
4675
- }
4676
- const text = replaceCapabilitiesBlock(before ?? `name: ${scaffoldConfigName(deployment)}\n`, caps);
4677
- mkdirSync(deployment, { recursive: true });
4678
- acquired = acquirePackage(deployment, "oats.framework", { expectPackage: "oats.framework",
4679
- catalog(id, selector) {
4680
- const selected = id === "oats.framework" ? entry : (Object.hasOwn(catalog, id) ? catalog[id] : null);
4681
- return selected?.url ? { url: selected.url, ref: selector || selected.ref, path: selected.path } : undefined;
4682
- },
4683
- assertCommittable(plan) {
4684
- const pkg = plan.packages.find(p => p.package === "oats.framework");
4685
- if (edition.packageIntegrity && pkg?.integrity !== edition.packageIntegrity) {
4686
- const lag = catalogOrigin === "bundled" ? " (the kernel's bundled catalog entry lags the edition's package; onboard from the workspace or point OATS_PACKAGE_CATALOG at the reviewed list)" : "";
4687
- throw Object.assign(new Error(`selected edition's same-repository package differs from the official acquisition; align the reviewed source and catalog explicitly${lag}`),
4688
- { code: "integrity-drift", details: { catalogOrigin, catalogRef: entry.ref, acquiredIntegrity: pkg?.integrity ?? null, editionPackageIntegrity: edition.packageIntegrity } });
4689
- }
4690
- for (const id of SETUP_CAPABILITIES) {
4691
- const cap = plan.capabilities.find(c => c.capability === id);
4692
- if (!cap || cap.package !== "oats.framework" || cap.layer || Object.values(cap.executableSurface || {}).some(value => Array.isArray(value) && value.length)) {
4693
- throw Object.assign(new Error(`setup bootstrap needs resources-only ${id}; executable surfaces require a separate explicit approval path`), { code: "approval-required" });
4694
- }
4695
- }
4696
- } });
4697
- 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" });
4698
- writeFileAtomic(file, text); configWritten = text;
4699
- const selected = resolveOatsConfig(deployment, SETUP_EXPERT);
4700
- if (selected.capabilities.length !== 2 || SETUP_CAPABILITIES.some(id => !selected.capabilities.some(c => c.id === id))) {
4701
- throw Object.assign(new Error("setup expert's effective configuration contains other capabilities; no soul or hook was created"), { code: "needs-configuration" });
4702
- }
4703
- // Required capabilities were checked resources-only before acquisition: no
4704
- // unrelated or executable soul-scaffold hooks can run during local creation.
4705
- assertNoSymlinkedParents(deployment, root, "onboard agents root");
4706
- assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4707
- assertSetupAbsent();
4708
- mkdirSync(root, { recursive: true });
4709
- created = coreCreateAgent(root, { name: SETUP_EXPERT, local: true, oatsCore: false, repo: deployment, work: "directory",
4710
- runtime: edition.declaration.runtime, model: edition.declaration.model, yolo: false,
4711
- description: edition.declaration.description, instructions: edition.instructions });
4712
- const pkg = acquired.installed.find(p => p.package === "oats.framework");
4713
- const source = parsePortableSource(`git:${catalogSource.url}@${pkg.commit}#${pkg.path}`);
4714
- const requires = { capabilities: Object.fromEntries(SETUP_CAPABILITIES.map(id => [id, { source: `${source.source}#${source.path}` }])) };
4715
- const soulFile = join(created.soul, "soul.yaml");
4716
- writeFileAtomic(soulFile, readFileSync(soulFile, "utf8") + `requires: ${JSON.stringify(requires)}\ndefaults: ${JSON.stringify(edition.declaration.defaults)}\n`
4717
- + `provenance: ${JSON.stringify({ kind: edition.source.kind, source: edition.source.source, revision: edition.source.revision, path: edition.source.path, ...(edition.source.workspaceRevision ? { workspaceRevision: edition.source.workspaceRevision } : {}) })}\n`);
4718
- const agent = findAgent(root, SETUP_EXPERT), composition = composeInstanceAgentsMd(created.soul, deployment, SETUP_EXPERT, "directory", "local");
4719
- planInstanceResources({ resolved: composition.resolved, soulDir: created.soul, agent, contextDir: deployment, composition });
4720
- 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."];
4721
- const result = { mode: "classic", captured: false, deployment, agentsRoot: root, ...created, source: edition.source, catalog: { origin: catalogOrigin, ref: entry.ref },
4722
- package: { id: pkg.package, version: pkg.version, commit: pkg.commit, path: pkg.path }, lockFile: acquired.lockFile,
4723
- capabilities: [...SETUP_CAPABILITIES], launched: false, next: { argv, command: argv.map(shellQuote).join(" ") } };
4724
- if (JSON_MODE) jsonOk(result);
4725
- 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}`); }
4726
- } catch (error) {
4727
- // Roll back only configuration bytes still exactly owned by this attempt.
4728
- // Acquired artifacts/locks and any incomplete new soul remain visible evidence.
4729
- let configRestored = false;
4730
- if (configWritten !== undefined) {
4731
- try {
4732
- if (lstatSync(configFile).isFile() && readFileSync(configFile, "utf8") === configWritten) {
4733
- if (configBefore === null) rmSync(configFile); else writeFileAtomic(configFile, configBefore);
4734
- configRestored = true;
4735
- }
4736
- } catch { /* never erase another writer's change or hide a failed rollback */ }
2872
+ if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); created.push(dir); }
2873
+ const local = { schemaVersion: 2, workspace: workspaceRef };
2874
+ writeFileAtomic(localFile, YAML.stringify(local));
2875
+ created.push(localFile);
2876
+ const agentsDir = join(dir, "agents");
2877
+ if (!existsSync(agentsDir)) { mkdirSync(agentsDir); created.push(agentsDir); }
2878
+ } catch (e) {
2879
+ rollback();
2880
+ return bail(e.code && String(e.code).startsWith("E_") ? e.code : "E_ONBOARD_FAILED", `cannot write ${shortPath(dir)}: ${e.message}`, { dir });
2881
+ }
2882
+ /** Only what THIS onboard created, only while still exactly ours: our local file, an EMPTY
2883
+ * agents/, an otherwise-empty <dir> (rmdirSync refuses a non-empty directory — evidence stays). */
2884
+ function rollback() {
2885
+ for (const p of [...created].reverse()) {
2886
+ try { if (p === localFile) rmSync(p, { force: true }); else rmdirSync(p); }
2887
+ catch { /* leave evidence rather than erase another writer's work */ }
4737
2888
  }
4738
- fail(error.code || "E_ONBOARD_FAILED", error.message, { deployment, agentsRoot: root, packageAcquired: !!acquired, soul: created?.soul, configRestored, launched: false, ...(error.details && typeof error.details === "object" ? error.details : {}) });
4739
2889
  }
2890
+ const bailRollback = (code, message, details) => { rollback(); return bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, rolledBack: true }); };
2891
+
2892
+ // (3) Exactly the `oats sync` body over the deployment just written. A failure while
2893
+ // DISCOVERING (unreadable remote, not a workspace host) rolls the two files back; once the
2894
+ // workspace has been read, the files stay (a lock may already be written).
2895
+ let ctx;
2896
+ try {
2897
+ const found = loadLocal(dir);
2898
+ ctx = { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
2899
+ } catch (e) { return bailRollback(e.code || "E_LOCAL_MISSING", e.message, e.details); }
2900
+ let discovered = false;
2901
+ const syncBail = (code, message, details) => (discovered
2902
+ ? bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, local: localFile })
2903
+ : bailRollback(code, message, details));
2904
+ const synced = await performSync(ctx, syncBail, { onDiscovered: () => { discovered = true; } });
2905
+
2906
+ // (4) The taught layout as next steps (design doc §4), and the envelope.
2907
+ const standalone = synced.discovery.standalone === true;
2908
+ const members = synced.report.members;
2909
+ // The setup expert is suggested only when THIS workspace lists a soul by that name (a
2910
+ // confirmed member's or, standalone, the repo's own); otherwise any listed soul is spawnable.
2911
+ const soulNames = synced.items.souls.map((s) => s.name);
2912
+ const setupExpert = soulNames.includes("oats-setup-expert");
2913
+ const spawnHint = setupExpert ? `oats spawn oats-setup-expert --dir ${shortPath(dir)}` : null;
2914
+ const anySoulHint = `spawn any listed soul: oats spawn <soul> --dir ${shortPath(dir)}${soulNames.length ? ` (e.g. ${soulNames.slice(0, 3).join(", ")})` : ""}`;
2915
+ // A member's clone goes beside oats-local.yaml under its repo name; `agents/` is the instance
2916
+ // homes, so a member called "agents" is cloned as `agents-repo/` (design doc §4).
2917
+ const cloneDirOf = (m) => join(dir, m.name === "agents" ? "agents-repo" : m.name);
2918
+ const clones = members.filter((m) => m.confirmed || (standalone && m.key === synced.discovery.key)).map((m) => ({ key: m.key, name: m.name, url: memberUrlOf(synced.discovery, m.key), dir: cloneDirOf(m) }));
2919
+ // Decision 26: the host publishes the member list to whoever can read it. When the host is
2920
+ // itself a member (the common `agents` shape) that is fine for an all-private or all-public
2921
+ // organisation; a mixed one needs a private host that is NOT a public member. The kernel
2922
+ // cannot see forge visibility, so it states the rule rather than judging.
2923
+ const hostIsMember = members.some((m) => m.key === synced.discovery.key);
2924
+ const hosting = { host: synced.discovery.key, hostIsMember, rule: "If any member is private, host oats-workspace.yaml in a private repo that is not a public member (a dedicated <org>/workspace repo); public contributors then use the standalone case (from: here capabilities + oats.core)." };
2925
+ const result = { onboardApi: 2, standalone: standalone || undefined, local: localFile, dir, agents: join(dir, "agents"), lock: synced.lockFile, sync: synced.report, hosting, next: { clone: clones, spawn: spawnHint, souls: soulNames.slice(0, 3) } };
2926
+ if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
2927
+
2928
+ console.log(`Onboarded ${shortPath(dir)} into workspace ${workspaceName(synced.discovery)} (${synced.discovery.key} @ ${short(synced.discovery.commit)}).${standalone ? `\n (standalone — the workspace of ${memberLabel(synced.discovery.key)} cannot be read from here; you get its own souls + oats.core)` : ""}\n`);
2929
+ printSyncReport(ctx, synced);
2930
+ console.log(`
2931
+ Layout (the taught convention — the kernel finds clones through oats-local.yaml, so any layout works):
2932
+ ${shortPath(dir)}/
2933
+ ├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
2934
+ ├── oats-lock.json exact commit + integrity + per-version executable approval per package
2935
+ ├── agents/ instance homes, each self-contained
2936
+ └── <member>/ clones of the members you will work IN (only those)
2937
+
2938
+ Next:
2939
+ 1. Clone the members you will work IN beside oats-local.yaml (discovery and resolution run over the
2940
+ remotes; only a soul's work target needs a clone):${clones.map((c) => `\n git clone ${c.url ?? c.key} ${shortPath(c.dir)}`).join("") || "\n (no confirmed members yet — see the membership rows above)"}
2941
+ A clone elsewhere is fine: point at it in oats-local.yaml under clones: { <repo key>: <abs path> }.
2942
+ 2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
2943
+ names every member, so if any member is private the host must be a private repo that is not
2944
+ a public member; public contributors then get the standalone case (from: here + oats.core).
2945
+ 3. ${setupExpert ? "Spawn the setup expert to guide the rest (souls, teams, provider settings, approvals):" : "No soul named oats-setup-expert is listed here —"}
2946
+ ${spawnHint ?? anySoulHint}${synced.approvalNeeded.length ? `\n (first: \`oats sync --dir ${shortPath(dir)}\` in a terminal to approve ${synced.approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")})` : ""}`);
2947
+ process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2948
+ }
2949
+
2950
+ /** The clone URL of a member row: what the remote observed (from the workspace's members: refs;
2951
+ * standalone, the one repo oats-local.yaml named). */
2952
+ function memberUrlOf(discovery, key) {
2953
+ for (const ref of discovery.workspace?.members || []) {
2954
+ try { const parsed = remoteModule.parseRepoRef(ref); if (parsed.key === key) return parsed.url; } catch { /* schema already validated */ }
2955
+ }
2956
+ if (discovery.standalone === true && discovery.key === key) return discovery.url ?? null;
2957
+ return null;
4740
2958
  }
4741
2959
 
4742
2960
  function createCmd() {
@@ -4769,7 +2987,7 @@ function createCmd() {
4769
2987
  // step here, not only at the refusal.
4770
2988
  const declared = r.declaredCapabilities || [];
4771
2989
  const inactive = declared.filter((id) => !(resolveOatsConfig(workspaceOf(root), name).capabilities || []).some((c) => c.id === id));
4772
- const next = inactive.length ? [{ code: "next-step", message: `${name} declares ${inactive.join(", ")}; before spawning, acquire if needed (oats install oats.framework) and activate: ${inactive.map((id) => `oats use ${id} --soul ${name}`).join(" && ")}` }] : [];
2990
+ const next = inactive.length ? [{ code: "next-step", message: `${name} declares ${inactive.join(", ")}; before spawning, pin their packages in oats-workspace.yaml packages: and declare them in soul.yaml capabilities: { <cap>: { from } } (workspace model v2)` }] : [];
4773
2991
  const notes = [...(r.notes || []), ...next];
4774
2992
  if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(notes.length ? { notes } : {}), ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
4775
2993
  for (const information of notes) console.error(`[${information.code}] ${information.message}`);
@@ -4813,9 +3031,11 @@ function capabilityCommand() {
4813
3031
  // manifests. Null-prototype because the dispatcher indexes it with the
4814
3032
  // namespace the operator typed on the command line.
4815
3033
  let capSettings = Object.create(null);
3034
+ let instanceModules = false;
4816
3035
  try {
4817
3036
  if (metaFile && existsSync(metaFile)) {
4818
3037
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
3038
+ instanceModules = !!(meta.modules && typeof meta.modules === "object");
4819
3039
  activeIds = (meta.capabilities || []).map((c) => c.id);
4820
3040
  for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
4821
3041
  context = meta.repo || context;
@@ -4829,7 +3049,9 @@ function capabilityCommand() {
4829
3049
  teamCtx = resolved.team;
4830
3050
  }
4831
3051
  } catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
4832
- const mans = Object.values(capabilityManifests(context)).filter((m) => m.command === cmd && m.commands);
3052
+ // Workspace model: an instance's own materialized modules are the command
3053
+ // namespaces available to it (instance.json.modules → <home>/.oats/modules).
3054
+ const mans = Object.values(capabilityManifests(instanceModules ? instanceHome : context)).filter((m) => m.command === cmd && m.commands);
4833
3055
  if (!mans.length) return NOT_DISPATCHED;
4834
3056
  if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
4835
3057
  const m = mans[0];
@@ -4931,59 +3153,6 @@ function typeCmd() {
4931
3153
  console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
4932
3154
  }
4933
3155
 
4934
- // ---------- injection eject ----------
4935
- function injectCmd() {
4936
- const sub = args[1];
4937
- const target = args[2];
4938
- if (sub !== "eject" || !target || target.startsWith("--")) die("usage: oats inject eject <capability-id|oats> [--dir <dir>]");
4939
- const dir = dirFlag();
4940
- const file = join(dir, "oats-config.yaml");
4941
- if (!existsSync(file)) die(`no oats-config.yaml at ${shortPath(dir)} — run oats init first`);
4942
- if (["checkout", "worktree", "attached", "workspace"].includes(target)) die("work-mode injection overrides were removed — the packaged briefings are the contract; work modes support only setup: (env bootstrap script)");
4943
- const isWorkMode = false;
4944
- const isKernel = target === "oats";
4945
- const src = isKernel ? packagedInject("oats", dir) : isWorkMode ? packagedInject(`work-${target}`, dir) : packagedInject(target, dir);
4946
- if (!src) die(`no packaged default injection found for "${target}"`);
4947
- const rel = isKernel ? ".agents/injections/oats-defaults/oats.md" : isWorkMode ? `.agents/injections/workmodes/${target}.md` : `.agents/injections/capabilities/${target}.md`;
4948
- const destAbs = join(dir, rel);
4949
- if (existsSync(destAbs)) die(`${shortPath(destAbs)} already exists — edit it directly (it is already your override)`);
4950
- let text = readFileSync(file, "utf8");
4951
- if (!isWorkMode && !isKernel) {
4952
- const caps = readCapabilitiesModel(file);
4953
- const entry = Object.values(caps.layers).find((e) => e && e !== "none" && e.capability === target) || caps.additive[target];
4954
- if (!entry) die(`capability "${target}" has no entry in ${shortPath(file)} — activate it first (oats use ${target})`);
4955
- const m = capabilityManifest(target, dir);
4956
- const owned = entry.from === "owned" || String(entry.from || "").startsWith("path:") || String(m?._origin || "").startsWith("owned:") || String(m?._origin || "").startsWith("path:");
4957
- if (owned) die(`"${target}" is owned/path-sourced — you own its source; edit its injects/ file directly instead of ejecting`);
4958
- entry["injection-override"] = rel;
4959
- text = replaceCapabilitiesBlock(text, caps);
4960
- } else {
4961
- const lines = text.replace(/\n*$/, "\n").split("\n");
4962
- const headRe = isKernel ? /^oats:\s*(#.*)?$/ : /^work-modes:\s*(#.*)?$/;
4963
- let idx = lines.findIndex((l) => headRe.test(l));
4964
- if (idx < 0) { lines.push("", isKernel ? "oats:" : "work-modes:"); idx = lines.length - 1; }
4965
- if (isKernel) {
4966
- lines.splice(idx + 1, 0, ` injection-override: ${rel}`);
4967
- const c = lines.findIndex((l, i2) => i2 > idx + 1 && l.trim() === `# injection-override: ${rel}`);
4968
- if (c >= 0) lines.splice(c, 1);
4969
- } else {
4970
- let mIdx = lines.findIndex((l, i2) => i2 > idx && new RegExp(`^ ${target}:`).test(l));
4971
- if (mIdx < 0) { lines.splice(idx + 1, 0, ` ${target}:`, ` injection-override: ${rel}`); }
4972
- else {
4973
- lines.splice(mIdx + 1, 0, ` injection-override: ${rel}`);
4974
- const c = lines.findIndex((l, i2) => i2 > mIdx + 1 && l.trim() === `# injection-override: ${rel}`);
4975
- if (c >= 0) lines.splice(c, 1);
4976
- }
4977
- }
4978
- text = lines.join("\n").replace(/\n*$/, "\n");
4979
- }
4980
- mkdirSync(dirname(destAbs), { recursive: true });
4981
- writeFileSync(destAbs, readFileSync(src, "utf8"));
4982
- writeFileSync(file, text);
4983
- console.log(`Ejected packaged injection → ${shortPath(destAbs)}`);
4984
- console.log(`Set injection-override in ${shortPath(file)}. Edit the ejected file; it no longer tracks package updates.`);
4985
- }
4986
-
4987
3156
  // ---------- update ----------
4988
3157
  function updateCmd() {
4989
3158
  const checkOnly = args.includes("--check");
@@ -5034,7 +3203,10 @@ function versionCmd() {
5034
3203
  // on it (an older CLI without the surface must fail closed with a
5035
3204
  // reason, not an argument error). `features`: kernel abilities a peer
5036
3205
  // must see before relying on them (retire-home: retire --home).
5037
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["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", "catalog", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2"], instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 2, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
3206
+ // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3207
+ // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3208
+ // never listed.
3209
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["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", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
5038
3210
  return;
5039
3211
  }
5040
3212
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -5172,7 +3344,7 @@ async function serverRouteCmd() {
5172
3344
  // The operations contract addresses an exact member context on the host,
5173
3345
  // so its explicit --dir travels; every other routed command takes its
5174
3346
  // scope from the registration.
5175
- const explicitScopeOk = ["inspect", "operation", "use", "soul", "launch-config"].includes(cmd);
3347
+ const explicitScopeOk = ["inspect", "operation", "soul", "launch-config"].includes(cmd);
5176
3348
  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");
5177
3349
  if (cmd === "launch-config") {
5178
3350
  const action = args[1];
@@ -5398,6 +3570,8 @@ async function serverRouteCmd() {
5398
3570
  // added lines rather than ~150 lines of pure whitespace churn, and keeps `git
5399
3571
  // blame` pointing at the commit that last changed each command.
5400
3572
  const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3573
+ /** Removed 0.24 verbs → their v2 replacement (workspace model v2, decision 5). Checked before capability dispatch. */
3574
+ const REMOVED_VERBS = { install: "oats sync", restore: "oats sync", init: "oats-local.yaml + oats sync", use: "soul.yaml capabilities: { <cap>: { from } } + workspace defaults", trust: "oats sync (approval is asked once per package version)", list: "oats workspace status | oats capabilities", catalog: "oats package add <id> <version> (bare versions resolve through package-catalog.json)", remove: "oats package remove <id>", migrate: "a rebuild (no migration: docs/design/2026-09-23-workspace-module-contracts.md)", config: "oats-local.yaml (host settings) and oats-workspace.yaml (shared)", inject: "injection overrides are not part of the workspace model yet; edit the capability inject in its member repo" };
5401
3575
  try {
5402
3576
  // Inspect explicit selectors with the existing parser before new-work routing,
5403
3577
  // including selectors before the command. Inherited captures are not prepare inputs.
@@ -5427,12 +3601,13 @@ if (cmd === "prepare" || captured?.args[0] === "prepare") {
5427
3601
  }
5428
3602
  if (cmd === "onboard" || captured?.args[0] === "onboard") {
5429
3603
  if (captured) {
5430
- if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit classic bootstrap and cannot use captured selectors");
5431
- die("onboard is explicit classic bootstrap and cannot use captured selectors");
3604
+ if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit workspace bootstrap and cannot use captured selectors");
3605
+ die("onboard is explicit workspace bootstrap and cannot use captured selectors");
5432
3606
  }
5433
3607
  if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
5434
- onboardCmd(); process.exit(0);
3608
+ await onboardCmd();
5435
3609
  }
3610
+ else {
5436
3611
  // Other commands retain their existing explicit/inherited selection rules.
5437
3612
  try { captured ??= capturedSelector(args); }
5438
3613
  catch (error) {
@@ -5451,7 +3626,7 @@ if (captured) {
5451
3626
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
5452
3627
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
5453
3628
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
5454
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
3629
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
5455
3630
  else if (cmd === "server") serverCmd();
5456
3631
  else if (cmd === "inspect") inspectCmd();
5457
3632
  else if (cmd === "operation") operationCmd();
@@ -5461,35 +3636,29 @@ else if (cmd === "doctor") {
5461
3636
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
5462
3637
  args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
5463
3638
  }
5464
- else if (cmd === "use") use();
5465
3639
  else if (cmd === "update") {
3640
+ // `oats update <package>` left with the installed tier (packages are pinned in
3641
+ // the workspace file: `oats package add`); only the kernel self-update remains.
5466
3642
  const t = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
5467
- // A selector without a package must never fall through to the kernel
5468
- // self-update (a different product) with the flag silently ignored.
5469
- if (!t && flag("to") !== undefined) { cmdFail("E_BAD_ARGS", "oats update --to needs a package: oats update <package> --to <ref> (or <package> <package>@<ref>)"); process.exit(1); }
5470
- t ? updatePackageCmd(t) : updateCmd();
3643
+ if (t || flag("to") !== undefined) { cmdFail("E_BAD_ARGS", "oats update takes no package: pin package versions in oats-workspace.yaml (`oats package add <id> <version>`), then `oats sync`; bare `oats update [--check] [--yes]` updates the kernel"); process.exit(1); }
3644
+ updateCmd();
5471
3645
  }
5472
3646
  else if (cmd === "type") typeCmd();
5473
- else if (cmd === "inject") injectCmd();
5474
- else if (cmd === "install") install();
5475
- else if (cmd === "config") configCmd();
5476
- else if (cmd === "trust") trust();
5477
- else if (cmd === "list") listCmd();
5478
- else if (cmd === "catalog") catalogCmd();
5479
3647
  else if (cmd === "readiness") readinessCmd();
5480
3648
  else if (cmd === "instance") instanceCmd();
5481
- else if (cmd === "remove") removeCmd();
5482
- else if (cmd === "migrate") migrateCmd();
5483
3649
  else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
5484
- else if (cmd === "init") init();
5485
- else if (cmd === "status") status();
3650
+ else if (cmd === "sync") await syncCmd();
3651
+ else if (cmd === "package") await packageCmd();
3652
+ else if (cmd === "workspace") await workspaceCmd();
3653
+ else if (cmd === "capabilities" || cmd === "souls") await itemsCmd(cmd);
3654
+ else if (cmd === "status") await status();
5486
3655
  else if (cmd === "pane") await paneCmd();
5487
3656
  else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
5488
3657
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
5489
3658
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
5490
3659
  else if (cmd === "session") await sessionCmd();
5491
3660
  else if (cmd === "schedule") scheduleCmd();
5492
- else if (cmd === "spawn") { try { spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3661
+ 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; } }
5493
3662
  else if (cmd === "retire") retireCmd();
5494
3663
  else if (cmd === "create") createCmd();
5495
3664
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -5498,14 +3667,24 @@ else if (cmd === "experimental") await experimentalCmd();
5498
3667
  // word, so without this it reaches the capability dispatch, which resolves the
5499
3668
  // config chain and reads every lock in it — and a scope whose lock the kernel
5500
3669
  // refuses could then not print its own usage, which is exactly when you need it.
3670
+ // A removed 0.24 verb names its v2 replacement in BOTH modes, before any capability namespace could shadow it.
3671
+ else if (cmd && Object.hasOwn(REMOVED_VERBS, cmd)) {
3672
+ const message = `unknown command "${cmd}" — removed by the workspace model v2; use ${REMOVED_VERBS[cmd]}`;
3673
+ if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: cmd, replacement: REMOVED_VERBS[cmd] });
3674
+ console.error(`oats: ${message}\n`);
3675
+ console.log(usageText());
3676
+ process.exit(1);
3677
+ }
5501
3678
  else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
5502
3679
  // No matching kernel command or capability namespace: in --json mode the help
5503
3680
  // text must NOT contaminate stdout — still one envelope object, nonzero exit.
5504
3681
  else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `unknown command "${cmd}" — no kernel subcommand or active capability namespace matches`);
5505
3682
  else {
3683
+ if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
5506
3684
  console.log(usageText());
5507
3685
  process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
5508
3686
  }
3687
+ } // end: every command but onboard
5509
3688
 
5510
3689
  /** The usage lines for one kernel command (its `oats <cmd> ...` lines and
5511
3690
  * their indented continuations), or the whole usage when none match. */
@@ -5555,7 +3734,7 @@ Usage:
5555
3734
  oats session start --server <id> start a stopped remote instance in its existing home
5556
3735
  --instance <name> | --home <abs> over its saved route; the server must advertise
5557
3736
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
5558
- oats inspect|operation|use|soul --server <id> the same commands on a registered server over its
3737
+ oats inspect|operation|soul --server <id> the same commands on a registered server over its
5559
3738
  ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
5560
3739
  is its own context; else the registered workspace);
5561
3740
  soul set --instructions-file streams the bytes; the
@@ -5564,9 +3743,11 @@ Usage:
5564
3743
  --instance <name> | --home <abs> attachments over its saved route (bytes stream on
5565
3744
  --file <path> [--json] ssh stdin; sha256 verified); the server must
5566
3745
  advertise session-upload (oats 0.22.13 or later)
5567
- oats onboard [--dir <deployment>] bootstrap a LOCAL setup expert from official
5568
- [--workspace <git:source[@revision]>] capabilities; classic path, not captured prepare;
5569
- [--force-existing] [--json] prints the next spawn command, never launches
3746
+ oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
3747
+ [--json] and agents/, then runs the oats sync path (lock v3;
3748
+ exit 2 while approvals are pending) and prints the
3749
+ next steps (clone members you work IN, spawn
3750
+ oats-setup-expert); creates no soul, spawns nothing
5570
3751
  oats create <name> [--local] [--no-oats-core] create an agent soul; --local = full
5571
3752
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
5572
3753
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
@@ -5650,28 +3831,21 @@ Usage:
5650
3831
  --soul shows final composed AGENTS.md
5651
3832
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
5652
3833
  optionally run the update; then run oats doctor
5653
- oats install [<source>] [--dir <d>] acquire + exact-lock a package closure
5654
- (git:host/org/repo@ref[#<path>], git URL,
5655
- local path, official catalog id) or a legacy
5656
- marketplace capability; never activates
5657
- #<path> selects the contained package root
5658
- (default oats-package; #. = repository root;
5659
- local paths are always exact directories)
5660
- [--recursive] [--no-requirements] bare \`oats install\` exactly restores this
5661
- [--accept-requirement <cmd> ...] chain's locked packages + capabilities; at a
5662
- [--json] team: scope (or with --recursive) it reconciles
5663
- the whole workspace — descendant scopes restore
5664
- once in path order (pruned discovery), then the
5665
- host-requirement consent gate runs;
5666
- --no-requirements = package-only (CI);
5667
- non-interactive runs never install host tools
5668
- unless each requirement is named explicitly;
5669
- --json = one envelope (failures carry the full
5670
- report under error.details)
5671
- oats list [--dir <d>] [--json] installed packages, exported capabilities,
5672
- scopes, trust state
5673
- oats catalog [--json] the effective official package catalog (read-only:
5674
- identity/discovery, no acquisition or trust)
3834
+ oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
3835
+ oats-local.yaml over its Git remote, confirm every
3836
+ member (reciprocal oats-membership.yaml), resolve
3837
+ packages: to exact commits, ask executable approval
3838
+ once per package version (TTY; otherwise list what
3839
+ needs it and exit 2), write oats-lock.json (v3) and
3840
+ report the diff
3841
+ oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
3842
+ | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3843
+ print the line to add (the file travels through Git)
3844
+ oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
3845
+ cannot-read / backlink-elsewhere), packages, approval
3846
+ oats capabilities [--dir <d>] [--json] every non-private capability of every confirmed
3847
+ oats souls [--dir <d>] [--json] member + the locked packages, with origin
3848
+ (member <key> @ <commit> | package <id> v<ver>) and team
5675
3849
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
5676
3850
  read-only Git observation of the instance's work
5677
3851
  tree: branch, status (renames kept), ahead/behind
@@ -5709,68 +3883,8 @@ Usage:
5709
3883
  <workspace>/.agents/worktrees/<repo>/<branch>)
5710
3884
  unless discarded; --delete-branch deletes the
5711
3885
  worktree's verified branch and implies discard
5712
- oats update <package> [<package>@<ref>] transactional package update: temp fetch,
5713
- [--to <ref>] [--dir <d>] closure validation, diff, lock replace,
5714
- all capability approvals invalidated; a
5715
- spec or --to moves a catalog lock to <ref>
5716
- oats remove <package> [--dir <d>] remove a package (refuses while config or
5717
- dependent packages reference it)
5718
- oats migrate [--dry-run] [--dir <d>] map this scope's v1 capability locks to
5719
- package locks (preserves config activation)
5720
- oats migrate --official [--recursive] guided upgrade of 0.18 bundled official
5721
- [--dry-run] [--dir <d>] [--json] capabilities to official packages: plans every
5722
- visible lock-owning scope first, applies each
5723
- transactionally, keeps custom/owned entries
5724
- untouched, and prints the exact trust/install
5725
- follow-up (held when the catalog cannot map yet)
5726
- oats migrate --from-oas [--recursive] convert a pre-rename OAS deployment in place:
5727
- [--dry-run] [--dir <d>] [--json] renames oas-* files, the oas: config key and
5728
- capability ids, then chains the guided package
5729
- conversion — one transaction per scope, any
5730
- failure restores the original OAS bytes
5731
- oats config diff [--config <template>] three-way report: your config vs the recorded
5732
- [--dir <d>] [--json] adopted base vs the template in the current exact
5733
- lock — reports only, never writes; the adopted
5734
- base supplies the package/template defaults
5735
- oats config sync [--accept <r>=local|package] apply the template's changes to your config,
5736
- [--dir <d>] [--json] region by region, preserving every untouched local
5737
- byte, comment and ordering; local-only edits stay;
5738
- conflicts need an explicit --accept and are never
5739
- chosen for you; advances the recorded base
5740
- oats config sync --reset --yes replace your config with the template verbatim;
5741
- [--config <template>] [--dir <d>] previews every local change it discards, refuses
5742
- [--json] without --yes, and keeps a recoverable .bak
5743
- oats config adopt <package> switch to another installed package's template,
5744
- [--config <template>] [--accept ...] rebasing your one local config; exactly one adopted
5745
- [--dir <d>] [--json] base survives, and a failed switch changes nothing
5746
- oats trust <capability> [--dir <dir>] approve that capability's commands, hooks, and
5747
- launch-environment authority at
5748
- the provider package's exact integrity
5749
- oats trust <package> --all-capabilities explicit bulk approval with a full
5750
- executable-surface summary
5751
- oats use <capability> activate for one config-owned target
5752
- [--global|--type <t>|--soul <s>] (--global is default); --disable excludes
5753
- [--disable] [--settings k=v [k2=v2 ...]] [--dir <d>]
5754
- oats use none --layer <layer> explicitly disable a fundamental layer
5755
3886
  oats type add <name> [--description <d>] declare an agent type (family) in config;
5756
3887
  oats type list souls join via create --type / soul.yaml
5757
- oats inject eject <cap|work-mode|oats> copy a packaged injection to the conventional
5758
- [--dir <d>] .agents/injections/ path and set injection-override
5759
- oats init [--raw] [--dir <dir>] [--json] create an oats-config.yaml here. Fundamental
5760
- [--knowledge <id|none>] layers are filled from what is already at this
5761
- [--messaging <id|none>] scope, else acquired from the official package
5762
- [--tasks <id|none>] that supplies them — capabilities materialize
5763
- [--tmux-mouse|--no-tmux-mouse] flat, executable surfaces stay untrusted, and
5764
- the whole run rolls back on any failure.
5765
- [--package <id|path|git-url>] instead: adopt one config TEMPLATE from a package
5766
- [--config <template>] as your own local config and record the exact
5767
- adopted base (named template, else the marked
5768
- default, else the only one).
5769
- [--template <name|path|git-url>] instead: seed from a template config (named via an
5770
- outer templates: map, a local file, or a git repo's
5771
- default-branch oats-config.yaml).
5772
- Every form refuses to overwrite an existing config;
5773
- --json = exactly one result envelope, noninteractive.
5774
3888
  oats root print this package's install root
5775
3889
  (adapters resolve the kernel from it)
5776
3890
 
@@ -5828,7 +3942,7 @@ The turn record (core — every conversation captured, searchable, replicated):
5828
3942
  oats <namespace> <command> [args…] run an operational command only when its
5829
3943
  capability is active (e.g. oats okf harvest)
5830
3944
 
5831
- Layers: ${LAYERS.join(", ")}. Level detection: ~ → laptop, .git → repo, else workspace.`;
3945
+ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
5832
3946
  }
5833
3947
  } catch (e) {
5834
3948
  if (!TYPED_CLI_FAILURES.has(e?.code)) throw e;