@awebai/oats 0.24.13 → 0.25.1

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 (58) hide show
  1. package/bin/oats.mjs +994 -2837
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/conventions.md +51 -24
  5. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  6. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  7. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  8. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  9. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  10. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  11. package/docs/design/2026-09-23-workspace-module-contracts.md +460 -0
  12. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  13. package/docs/design/README.md +20 -8
  14. package/docs/design/operations-contract.md +1 -0
  15. package/docs/design/package-engine-contract.md +1 -1
  16. package/docs/design/package-runtime-api.md +1 -1
  17. package/docs/desktop-cli-api.md +356 -5
  18. package/docs/desktop-succession.md +12 -6
  19. package/docs/desktop.md +9 -4
  20. package/docs/execution-targets.md +16 -4
  21. package/docs/first-team.md +107 -224
  22. package/docs/implementation.md +41 -11
  23. package/docs/integrations.md +50 -47
  24. package/docs/knowledge-capability-authoring.md +10 -4
  25. package/docs/knowledge-migration.md +21 -12
  26. package/docs/knowledge-reference/package-craft.md +11 -3
  27. package/docs/knowledge.md +60 -18
  28. package/docs/layers.md +3 -3
  29. package/docs/migration-from-oas.md +20 -9
  30. package/docs/oats-local.schema.json +50 -0
  31. package/docs/oats-membership.schema.json +23 -0
  32. package/docs/oats-workspace.schema.json +133 -48
  33. package/docs/official-marketplace.md +9 -6
  34. package/docs/packages.md +229 -440
  35. package/docs/rebuild-to-v2.md +347 -0
  36. package/docs/release-notes/v0.25.0.md +99 -0
  37. package/docs/release-notes/v0.25.1.md +94 -0
  38. package/docs/schedules.md +12 -6
  39. package/docs/soul.schema.json +41 -68
  40. package/docs/souls-and-instances.md +175 -108
  41. package/docs/workspace-adoption.md +70 -345
  42. package/docs/workspaces.md +436 -119
  43. package/lib/core.mjs +462 -61
  44. package/lib/instance-resolution.mjs +387 -0
  45. package/lib/materialize.mjs +580 -0
  46. package/lib/operator-dispatch.mjs +117 -0
  47. package/lib/packages.mjs +558 -1269
  48. package/lib/remote.mjs +718 -0
  49. package/lib/resolve.mjs +638 -0
  50. package/lib/schedule.mjs +90 -16
  51. package/lib/workspace.mjs +654 -0
  52. package/package.json +1 -1
  53. package/lib/portable-migration-artifacts.mjs +0 -135
  54. package/lib/portable-migration-evidence.mjs +0 -305
  55. package/lib/portable-migration-store.mjs +0 -199
  56. package/lib/portable-migration.mjs +0 -104
  57. package/lib/portable-onboarding-acceptance.mjs +0 -66
  58. package/lib/setup-expert-source.mjs +0 -100
package/bin/oats.mjs CHANGED
@@ -3,43 +3,45 @@
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,
42
- } from "../lib/packages.mjs";
38
+ assertNoSymlinkedParents, writeFileAtomic,
39
+ LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage,
40
+ classifyPackageValue, parsePackageRequest, executablesDigestAt } from "../lib/packages.mjs";
41
+ import { loadLocal, discoverWorkspace, validateWorkspace } from "../lib/workspace.mjs";
42
+ import * as remoteModule from "../lib/remote.mjs";
43
+ import { createInterface } from "node:readline/promises";
44
+ import YAML from "yaml";
43
45
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
44
46
  import { spawnSync as spawnSyncProc } from "node:child_process";
45
47
  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 +51,22 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
49
51
  import { capturedSelector } from "../lib/captured-selector.mjs";
50
52
  import { inspectCapturedPiOutcome } from "../lib/captured-pi-host.mjs";
51
53
  import { readCapturedResolution } from "../lib/captured-resolutions.mjs";
52
- import { readLock3 } from "../lib/portable-lock.mjs";
53
54
  import { oatsError } from "../lib/errors.mjs";
54
- import { readPortableBytes } from "../lib/portable-files.mjs";
55
- import { canonicalJson, parseStrictJson } from "../lib/portable-values.mjs";
55
+ import { canonicalJson } from "../lib/portable-values.mjs";
56
56
  import { readPortablePreparationRequest } from "../lib/portable-onboarding-request.mjs";
57
57
  import { portableScope } from "../lib/portable-state.mjs";
58
58
  import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../lib/captured-operation-process.mjs";
59
59
  import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
60
- import { loadSetupExpertEdition, SETUP_EXPERT, SETUP_CAPABILITIES } from "../lib/setup-expert-source.mjs";
61
60
  import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
62
61
  import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
63
62
  const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
64
63
  import { readinessOf, policyOf } from "../lib/readiness.mjs";
65
64
  import { readEvents } from "../lib/instance-events.mjs";
66
- import { parsePortableSource } from "../lib/source-spec.mjs";
67
65
 
68
66
  const args = process.argv.slice(2);
69
67
  let cmd = args[0];
70
68
  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"]);
69
+ 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
70
  const flag = (name) => {
73
71
  const i = args.indexOf(`--${name}`);
74
72
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -83,6 +81,7 @@ function valueFlag(name) {
83
81
  return value;
84
82
  }
85
83
  const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
84
+ const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
86
85
  /** Resolve the --dir flag with central validation: a value-taking flag given
87
86
  * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
88
87
  * uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
@@ -506,26 +505,6 @@ function scaffoldConfigName(dir) {
506
505
  return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
507
506
  }
508
507
 
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
508
  // ---------- doctor ----------
530
509
  /** Doctor must diagnose, not crash: a stale activation of a retired
531
510
  * capability fails config resolution — surface the cleanup instruction
@@ -560,31 +539,32 @@ function doctorComposition(ctx, soulName) {
560
539
  if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
561
540
  return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
562
541
  }
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
- }
542
+
543
+ /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
544
+ * walking up from ctx (oats-local.yaml) and the lock v3 beside it. Doctor never
545
+ * goes to the network; membership and discovery are `oats sync` / `oats workspace status`. */
546
+ function doctorLockData(ctx) {
547
+ const out = { local: null, localError: null, lockFile: null, packages: [], lockError: null };
548
+ let lockDir = ctx;
549
+ try {
550
+ const found = loadLocal(ctx);
551
+ out.local = { path: found.path, workspace: found.local.workspace };
552
+ lockDir = dirname(found.path);
553
+ } catch (e) {
554
+ if (e?.code === "E_WORKSPACE_SCHEMA") out.localError = { code: e.code, message: e.message };
555
+ else if (e?.code !== "E_LOCAL_MISSING") throw e;
556
+ }
557
+ const file = join(lockDir, LOCK_FILE);
558
+ if (!existsSync(file)) return out;
559
+ out.lockFile = file;
560
+ try {
561
+ const lock = readLock(lockDir);
562
+ 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 }));
563
+ } catch (e) {
564
+ if (e?.code !== "E_LOCK_SCHEMA") throw e;
565
+ out.lockError = { code: e.code, message: e.message, file: e.details?.file ?? file };
580
566
  }
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 };
567
+ return out;
588
568
  }
589
569
 
590
570
  /** The health of ONE materialized capability, against the rows it was projected
@@ -608,7 +588,7 @@ function hasExecutableSurface(manifest) {
608
588
  }
609
589
  function capabilityHealth(level, cap, capRow, pkgRow) {
610
590
  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` };
591
+ 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
592
  let integrity;
613
593
  try { integrity = capabilityArtifactIntegrity(dir); }
614
594
  catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
@@ -622,113 +602,10 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
622
602
  catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
623
603
  }
624
604
  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}\`` };
605
+ 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
606
  return { status: "ok", code: null, dir, integrity, detail: null };
627
607
  }
628
608
 
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
609
  // ---------- inspect: one authoritative answer for GUIs ----------
733
610
  /** Souls, capabilities (installed state and health, separately from
734
611
  * activation), effective layer bindings and declared operations for a
@@ -937,7 +814,7 @@ function computeInspect({ onFail } = {}) {
937
814
  // Capabilities: installed state and health from the package engine (exactly
938
815
  // what `oats list` reports), owned/path manifests beside them, and the
939
816
  // ACTIVATION for the selected soul (or global) from the resolver.
940
- const mans = capabilityManifests(ctx);
817
+ const mans = capabilityManifests(manifestSource(meta, home, ctx));
941
818
  let lockError = null;
942
819
  const byId = new Map();
943
820
  try {
@@ -998,7 +875,7 @@ function computeInspect({ onFail } = {}) {
998
875
  const operations = manifestOperations(m).map((op) => {
999
876
  let reason = null;
1000
877
  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})`;
878
+ else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (approve it in oats sync)`;
1002
879
  else if (entry.health.status !== "ok") reason = entry.health.detail || entry.health.status;
1003
880
  else if (missingRequires.length) reason = `${entry.id} requires ${missingRequires.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
1004
881
  else if (op.context === "home" && !home) reason = "needs a running home (--home)";
@@ -1201,7 +1078,7 @@ function operationCmd() {
1201
1078
  }
1202
1079
  // Provider resolution: the snapshot's active capabilities for a home, the
1203
1080
  // config for a soul/scope.
1204
- const mans = capabilityManifests(ctx);
1081
+ const mans = capabilityManifests(manifestSource(meta, home, ctx));
1205
1082
  let provider, settings, team, disabled = null;
1206
1083
  if (meta) {
1207
1084
  const ids = (meta.capabilities || []).map((c) => c.id);
@@ -1222,7 +1099,7 @@ function operationCmd() {
1222
1099
  const op = manifestOperations(provider).find((o) => o.name === opName);
1223
1100
  if (!op) bail("E_OPERATION_UNKNOWN", `${provider.capability} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
1224
1101
  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})`);
1102
+ if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (approve it in oats sync)`);
1226
1103
  const missingReq = capabilityMissingRequires(provider.capability, ctx);
1227
1104
  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
1105
  if (op.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
@@ -1347,11 +1224,14 @@ async function soulCmd() {
1347
1224
  function doctorJson(dir) {
1348
1225
  const ctx = resolve(dir || process.cwd());
1349
1226
  const soulName = flag("soul");
1227
+ const ws = doctorLockData(ctx);
1228
+ // A v2 deployment (oats-local.yaml found walking up) has NO config chain, layers,
1229
+ // acquired packages or installed tier: those keys are omitted, not emitted empty.
1230
+ if (ws.local) { console.log(JSON.stringify(doctorWorkspaceJson(ctx, soulName, ws), null, 2)); return; }
1350
1231
  const r = resolveForDoctor(ctx, soulName, { json: true });
1351
1232
  const mans = capabilityManifests(ctx);
1352
1233
  const composition = doctorComposition(ctx, soulName);
1353
1234
  const chain = configChain(ctx);
1354
- const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
1355
1235
  const oasScopes = detectOasScopes(ctx);
1356
1236
  console.log(JSON.stringify({
1357
1237
  schemaVersion: 1,
@@ -1377,34 +1257,77 @@ retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); }
1377
1257
  retiredArtifacts: Object.entries(mans)
1378
1258
  .filter(([id]) => retiredCapabilityReason(id))
1379
1259
  .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,
1260
+ // Workspace model v2 (offline view): the lock (v3) and the deployment
1261
+ // declaration. Human and JSON doctor derive from ONE computation; a
1262
+ // fail-closed lock read is diagnosed via lockError, never consumed as data.
1263
+ workspace: ws.local ? { file: ws.local.path, ref: ws.local.workspace } : null,
1264
+ workspaceError: ws.localError,
1265
+ lockFile: ws.lockFile,
1266
+ packages: ws.packages,
1267
+ lockError: ws.lockError,
1389
1268
  composedInstructions: composition?.text,
1390
1269
  instructionBlocks: composition?.blocks,
1391
1270
  }, null, 2));
1392
1271
  }
1393
1272
 
1273
+ /** The v2 doctor payload: the deployment declaration + lock (offline) and, with
1274
+ * --soul, the composed instructions. No v1 keys (chain/layers/acquired/injects…). */
1275
+ function doctorWorkspaceJson(ctx, soulName, ws) {
1276
+ const composition = doctorComposition(ctx, soulName);
1277
+ return {
1278
+ schemaVersion: 1, workspaceApi: 2, context: ctx,
1279
+ workspace: { file: ws.local.path, ref: ws.local.workspace },
1280
+ workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
1281
+ information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
1282
+ composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
1283
+ };
1284
+ }
1285
+ /** Kernel/bridge version skew (published in lockstep from one tag). */
1286
+ function doctorVersionSkew() {
1287
+ const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
1288
+ if (!existsSync(piPkgFile)) return;
1289
+ const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
1290
+ if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
1291
+ }
1292
+ /** The "Workspace (v2, offline view)" + "Locked packages" sections, shared by both doctor shapes. */
1293
+ function printDoctorWorkspace(ws) {
1294
+ console.log("\nWorkspace (v2, offline view):");
1295
+ if (ws.local) console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
1296
+ else if (ws.localError) console.log(` ERROR: ${ws.localError.message} [${ws.localError.code}]`);
1297
+ else console.log(" (no oats-local.yaml found walking up — this scope realizes no v2 workspace; run `oats sync` from one that does)");
1298
+ console.log("\nLocked packages (oats-lock.json v3):");
1299
+ if (ws.lockError) {
1300
+ console.log(` ERROR: ${ws.lockError.message} [${ws.lockError.code}]`);
1301
+ if (ws.lockError.file) console.log(` the lock is never auto-repaired; delete ${shortPath(ws.lockError.file)} and run \`oats sync\``);
1302
+ } else if (!ws.packages.length) console.log(ws.lockFile ? " (none)" : " (no lock yet — run `oats sync`)");
1303
+ for (const p of ws.packages) {
1304
+ console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.approved ? `approved ${p.approved.at}` : "APPROVAL NEEDED (oats sync)"}`);
1305
+ if (p.capabilities.length) console.log(` capabilities: ${p.capabilities.join(", ")}`);
1306
+ }
1307
+ console.log(" membership, discovery and drift need the remotes: `oats workspace status`, `oats sync`.");
1308
+ }
1394
1309
  function doctor(dir) {
1395
1310
  const ctx = resolve(dir || process.cwd());
1396
1311
  const soulName = flag("soul");
1312
+ const ws = doctorLockData(ctx);
1313
+ console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
1314
+ doctorVersionSkew();
1315
+ if (ws.local) {
1316
+ // A v2 deployment: nothing is installed and there is no config chain — the v1
1317
+ // sections (Config chain / Layers / Kernel injection / Acquired packages / lock
1318
+ // warnings) would describe a tier this deployment does not have.
1319
+ const composition = doctorComposition(ctx, soulName);
1320
+ printDoctorWorkspace(ws);
1321
+ if (soulName) {
1322
+ const information = operationalKnowledgeNote(composition, soulName);
1323
+ if (information) console.log(`\nINFO: ${information}`);
1324
+ console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
1325
+ } else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
1326
+ return;
1327
+ }
1397
1328
  const chain = configChain(ctx);
1398
1329
  const r = resolveForDoctor(ctx, soulName);
1399
1330
  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
1331
 
1409
1332
  console.log("Config chain (closest first):");
1410
1333
  if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
@@ -1485,7 +1408,7 @@ function doctor(dir) {
1485
1408
  for (const [id, lock] of Object.entries(locks)) {
1486
1409
  const retiredReason = retiredCapabilityReason(id);
1487
1410
  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\``);
1411
+ if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats sync\``);
1489
1412
  }
1490
1413
  for (const [id, m] of Object.entries(mans)) {
1491
1414
  if (!String(m._origin).startsWith("installed:")) continue;
@@ -1500,54 +1423,10 @@ function doctor(dir) {
1500
1423
  }
1501
1424
  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
1425
 
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
- }
1426
+ // Workspace model v2: nothing is installed. Doctor reports the lock (v3) and
1427
+ // the deployment declaration OFFLINE — membership, discovery and package
1428
+ // resolution go to the remotes and belong to `oats sync` / `oats workspace status`.
1429
+ printDoctorWorkspace(ws);
1551
1430
 
1552
1431
  if (soulName) {
1553
1432
  const information = operationalKnowledgeNote(composition, soulName);
@@ -1557,86 +1436,6 @@ function doctor(dir) {
1557
1436
  }
1558
1437
 
1559
1438
  // ---------- 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
1439
  /** Replace (or append, or drop with "") the top-level launch-configs block:
1641
1440
  * the span from its key line (bare, quoted, or the inline `launch-configs: {...}`
1642
1441
  * form) to the next top-level line is replaced; every byte before and after
@@ -1887,2248 +1686,570 @@ async function launchConfigCmd() {
1887
1686
  }
1888
1687
 
1889
1688
 
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) => {
1689
+ /** `oats instance <git|diff> <instance>` — K1: read-only Git observation of one
1690
+ * instance's work tree. The instance is addressed qualified: an explicit
1691
+ * --home, or a name under the --dir scope (team roots included) that resolves
1692
+ * to exactly one home; several homes refuse with every candidate named. */
1693
+ function instanceCmd() {
1694
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1695
+ const sub = args[1], name = args[2];
1696
+ 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]";
1697
+ if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
1698
+ dropAmbientRoot();
1699
+ if (sub === "events") {
1700
+ // K7: typed producer events, bounded window; nothing inferred.
1701
+ const homeOpt = flag("home"); if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1702
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1703
+ const limit = flag("limit"); const since = flag("since");
1704
+ if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
1914
1705
  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)})`);
1706
+ const { resolveInstance } = await_import_lifecycle();
1707
+ // K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
1708
+ // exactly this name under the scope (E_HOME_MISMATCH otherwise).
1709
+ const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
1710
+ const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
1711
+ if (JSON_MODE) { jsonOk(ev); return; }
1712
+ 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})` : ""}`);
1713
+ 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
1714
  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;
1715
+ } catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
1939
1716
  }
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}\`.`);
1717
+ if (sub === "stop") {
1718
+ // K3: plan → apply. The plan is what a confirmation shows; apply carries
1719
+ // its revision back and refuses if reality moved.
1720
+ const homeOpt = flag("home");
1721
+ if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1722
+ let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1723
+ const recursive = !args.includes("--no-recursive");
1724
+ const wantPlan = args.includes("--plan"), wantApply = args.includes("--apply");
1725
+ if (wantPlan === wantApply) return bail("E_BAD_ARGS", "stop needs exactly one of --plan or --apply");
1726
+ try {
1727
+ if (wantPlan) {
1728
+ const plan = planStop(dirFlag(), root, name, { home: homeOpt, recursive });
1729
+ if (JSON_MODE) { jsonOk(plan); return; }
1730
+ console.log(`stop ${name}${recursive ? " (and recorded children)" : ""} — plan ${plan.planRevision}`);
1731
+ 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" : ""}`);
1732
+ for (const n of plan.notes) console.log(` note: ${n}`);
1733
+ console.log(`apply with: oats instance stop ${name} --apply --plan-revision ${plan.planRevision} --idempotency-key <key>`);
1734
+ return;
1735
+ }
1736
+ const rev = flag("plan-revision"), key = flag("idempotency-key"), grace = flag("grace-ms");
1737
+ if (rev === true || key === true || grace === true) return bail("E_BAD_ARGS", usage);
1738
+ const receipt = applyStop(dirFlag(), root, name, { home: homeOpt, recursive, planRevision: rev, idempotencyKey: key, ...(grace !== undefined ? { graceMs: Number(grace) } : {}) });
1739
+ if (JSON_MODE) { jsonOk(receipt); return; }
1740
+ for (const r of receipt.results) console.log(` ${r.instance}: ${r.ok ? (r.stopped ? "stopped" : `already ${r.state}`) : `${r.code} — ${r.message}`}`);
1741
+ 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");
1742
+ if (!receipt.ok) process.exit(1);
1956
1743
  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;
1744
+ } catch (e) { return bail(e.code || "E_LIFECYCLE_FAILED", e.message, e.plan ? { plan: e.plan } : e.candidates ? { candidates: e.candidates } : undefined); }
1991
1745
  }
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)}`);
1746
+ let home = flag("home");
1747
+ if (home === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1748
+ if (home !== undefined && !isAbsolute(home)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
1749
+ if (home === undefined) {
1750
+ let root;
1751
+ try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
1752
+ let r; try { r = resolveOatsConfig(dirFlag()); } catch (e) { return bail(e.code || "E_CONFIG_BROKEN", e.message); }
1753
+ const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])].map((p) => realOrResolved(p)))];
1754
+ const candidates = [];
1755
+ for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
1756
+ if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
1757
+ if (candidates.length > 1) return bail("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`, { candidates });
1758
+ home = candidates[0].home;
1759
+ } else if (basename(home) !== name) return bail("E_HOME_MISMATCH", `--home ${home} is not the home of instance ${JSON.stringify(name)}`);
1760
+ try {
1761
+ if (sub === "git") {
1762
+ const observed = observeInstanceGit(home);
1763
+ if (JSON_MODE) { jsonOk(observed); return; }
1764
+ const o = observed.observation;
1765
+ console.log(`${observed.instance} — ${shortPath(o.worktree)} @ ${o.branch ?? (o.detached ? `detached ${o.revision.slice(0, 12)}` : "unborn")}`);
1766
+ console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
1767
+ console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
1768
+ console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
1769
+ for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
1770
+ for (const n of observed.notes) console.log(` note: ${n}`);
1771
+ return;
2035
1772
  }
1773
+ const fileId = flag("file"), revision = flag("revision"), indexRevision = flag("index-revision");
1774
+ if (fileId === true || revision === true || indexRevision === true) return bail("E_BAD_ARGS", usage);
1775
+ const d = diffInstanceFile(home, { fileId, revision, indexRevision });
1776
+ if (JSON_MODE) { jsonOk(d); return; }
1777
+ 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]` : ""}`);
1778
+ if (!d.binary) process.stdout.write(d.patch);
1779
+ } catch (e) {
1780
+ bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
2036
1781
  }
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
1782
  }
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;
1783
+ /** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
1784
+ function readinessCmd() {
1785
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1786
+ dropAmbientRoot();
1787
+ // A captured incarnation's readiness comes from its retained resolution, not
1788
+ // from the current configuration this command reads; refuse before inspecting.
1789
+ const homeArg = flag("home");
1790
+ if (homeArg && homeArg !== true) {
1791
+ let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
1792
+ 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
1793
  }
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(", ")}`);
1794
+ const inspect = computeInspect({ onFail: bail });
1795
+ if (!inspect) return;
1796
+ const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
1797
+ const verify = args.includes("--verify-signatures");
1798
+ let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
1799
+ const deploymentDir = inspect.scope?.context ?? null;
1800
+ // Echo the exact selector this read was made with, so a consumer can bind the
1801
+ // result to its own admitted target without inventing a revision.
1802
+ // Every field is the argument AS GIVEN (no realpath): a consumer compares it
1803
+ // byte-exact with what it sent. The canonical scope is subject.context.
1804
+ const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
1805
+ const agentsRootArg = given("agents-root"), dirArg = given("dir");
1806
+ const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
1807
+ : soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
1808
+ : { kind: "scope", dir: dirArg };
1809
+ const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
1810
+ if (args.includes("--policy")) {
1811
+ const homeOpt = flag("home");
1812
+ let meta = null;
1813
+ if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
1814
+ readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
1815
+ readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
2145
1816
  }
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)}`);
1817
+ if (JSON_MODE) { jsonOk(readiness); return; }
1818
+ 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`}`);
1819
+ for (const [name, check] of Object.entries(readiness.checks)) {
1820
+ console.log(` ${name}: ${check.status}`);
1821
+ 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
1822
  }
1823
+ 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"}`);
1824
+ for (const n of readiness.notes) console.log(` note: ${n}`);
2163
1825
  }
1826
+ // ---------- workspace model v2: sync / package / workspace status / capabilities / souls ----------
1827
+ // Contract: docs/design/2026-09-23-workspace-module-contracts.md §6. Nothing is
1828
+ // installed: `oats-local.yaml` names the workspace, discovery runs over the Git
1829
+ // remotes (lib/workspace.mjs), packages resolve to exact commits (lib/packages.mjs,
1830
+ // lock v3) and the only persisted state is `oats-lock.json` beside oats-local.yaml.
2164
1831
 
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();
1832
+ /** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
1833
+ * the content-addressed fetch cache (tests never touch ~/.cache). */
1834
+ function remoteOptionsFromEnv() {
1835
+ const cacheDir = process.env.OATS_REMOTE_CACHE;
1836
+ return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
2173
1837
  }
2174
1838
 
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 */ }
1839
+ /** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
1840
+ function workspaceContext(bail) {
1841
+ const dir = dirFlag();
1842
+ let found;
1843
+ try { found = loadLocal(dir); }
1844
+ catch (e) { return bail(e.code || "E_LOCAL_MISSING", e.message, e.details); }
1845
+ return { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
1846
+ }
1847
+
1848
+ /** The official catalog's package map (package-catalog.json; OATS_PACKAGE_CATALOG overrides). */
1849
+ function catalogForSync(bail) {
1850
+ try { return officialPackageCatalog(); }
1851
+ catch (e) { return bail(e.code || "E_PACKAGE_MISSING", e.message); }
1852
+ }
1853
+
1854
+ /** The package requests of a standalone view: the catalog's own pin of the package
1855
+ * providing oats.core (decision 25) — nothing else, since the version list lives in
1856
+ * the workspace file we cannot read. The request is written as resolvePackages
1857
+ * expects a CATALOG entry: the bare version (`v1.1.3`), never the catalog's raw ref
1858
+ * (`oats-framework/v1.1.3` is a tag PATH the one grammar refuses); parsePackageRequest
1859
+ * recomposes the tag from the catalog's own convention.
1860
+ * → { packages: { <id>: <version> }, problems: [ { code: "E_PACKAGE_MISSING", … } ] } */
1861
+ function standalonePackages(catalog) {
1862
+ let id = "oats.framework", file = process.env.OATS_PACKAGE_CATALOG || null;
1863
+ 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 */ }
1864
+ const version = standaloneCatalogVersion(catalog?.[id]?.ref);
1865
+ if (version) return { packages: { [id]: version }, problems: [] };
1866
+ const why = catalog?.[id] ? `its ref ${JSON.stringify(catalog[id].ref)} carries no version` : `it has no entry ${JSON.stringify(id)}`;
1867
+ 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` }] };
1868
+ }
1869
+ /** The bare version of a catalog ref: its LAST path segment when that is a version
1870
+ * (`v1.1.3` → `v1.1.3`; `oats-framework/v1.1.3` → `v1.1.3`; `main` → null). */
1871
+ function standaloneCatalogVersion(ref) {
1872
+ if (typeof ref !== "string" || !ref.trim()) return null;
1873
+ const tail = ref.trim().split("/").filter(Boolean).pop() ?? "";
1874
+ return classifyPackageValue(tail).kind === "catalog" ? tail : null;
1875
+ }
1876
+
1877
+ /** Discover the workspace named by oats-local.yaml over the real remote. */
1878
+ async function discoverForCli(ctx, bail) {
1879
+ // The standalone case (decisions 10/25) is a discovery too: the repo's own view
1880
+ // plus the kernel's oats.core default — discoverOrStandalone decides.
1881
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { remoteOptions: ctx.remoteOptions }); }
1882
+ catch (e) {
1883
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1884
+ throw e;
2211
1885
  }
2212
- return byLevel;
2213
1886
  }
2214
1887
 
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;
1888
+ const short = (oid) => (typeof oid === "string" ? oid.slice(0, 8) : "?");
1889
+ /** Where a reader takes capability manifests from: the instance home's own
1890
+ * materialized modules when the home has them (workspace model), else the
1891
+ * context directory (classic chain). */
1892
+ const manifestSource = (meta, home, ctx) => (meta && meta.modules && typeof meta.modules === "object" && home ? realOrResolved(home) : ctx);
1893
+ /** Display name of a discovery: the workspace's name, or the standalone label (decision 10). */
1894
+ const workspaceName = (discovery) => discovery.workspace?.name ?? `standalone:${memberLabel(discovery.key)}`;
1895
+ const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || String(key);
1896
+ const teamLabel = (team) => team ?? "unassigned";
1897
+ const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
1898
+
1899
+ /** Rows of every non-private soul/capability of confirmed members + locked package capabilities. */
1900
+ function workspaceItems(discovery, lock, { includePrivate = false } = {}) {
1901
+ const souls = [];
1902
+ const capabilities = [];
1903
+ for (const m of discovery.members) {
1904
+ if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
1905
+ 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 });
1906
+ 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 });
1907
+ }
1908
+ for (const ext of discovery.external || []) {
1909
+ const s = ext.soul;
1910
+ 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 });
1911
+ }
1912
+ for (const [id, entry] of Object.entries(lock?.packages || {})) {
1913
+ 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 });
1914
+ }
1915
+ 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);
1916
+ souls.sort(byName);
1917
+ capabilities.sort(byName);
1918
+ return { souls, capabilities };
2240
1919
  }
2241
1920
 
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,
1921
+ /** Membership rows for `sync` / `workspace status`. */
1922
+ function memberRows(discovery) {
1923
+ return discovery.members.map((m) => ({
1924
+ 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,
1925
+ souls: m.souls.map((s) => s.name), capabilities: m.capabilities.map((c) => c.name), publishes: m.publishes ?? null,
2253
1926
  }));
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
1927
  }
2267
1928
 
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" };
1929
+ /** Package rows for `sync` / `workspace status` from the lock. */
1930
+ function packageRows(lock) {
1931
+ 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 }));
1932
+ }
2271
1933
 
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
- });
1934
+ /** Print a padded table: rows are arrays of strings. */
1935
+ function printTable(header, rows) {
1936
+ const all = [header, ...rows];
1937
+ const widths = header.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? "").length)));
1938
+ for (const r of all) console.log(" " + r.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1939
+ }
2278
1940
 
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
- }
1941
+ /** Executables digest of a locked package, read over the remote at its locked commit. */
1942
+ async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOptions) {
1943
+ // ONE definition of the approval digest (lib/packages.mjs executablesDigestAt) — the
1944
+ // same function resolveSoul re-runs at spawn (M3), so sync and spawn can never disagree.
1945
+ const req = parsePackageRequest(id, workspace.packages[id], catalog);
1946
+ const { digest, executables } = await executablesDigestAt(remoteModule, req.remoteRef, entry.commit, entry.path, entry.capabilities ?? null, { remoteOptions });
1947
+ const targets = executables.map((x) => `${x.capability}: ${x.kind} ${x.name} → ${x.target}`);
1948
+ return { digest, targets };
2298
1949
  }
2299
1950
 
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); }
1951
+ /** One yes/no question on the terminal (TTY only; the caller checks). */
1952
+ async function askYesNo(question) {
1953
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1954
+ try {
1955
+ const answer = (await rl.question(question)).trim().toLowerCase();
1956
+ return answer === "y" || answer === "yes";
1957
+ } finally { rl.close(); }
1958
+ }
1959
+
1960
+ /** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
1961
+ * way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
1962
+ * `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
1963
+ * `bail` never returns (it exits the process with the caller's error shape).
1964
+ * → { report, lock, discovery, approvalNeeded, interactive, items, lockFile } */
1965
+ async function performSync(ctx, bail, { onDiscovered } = {}) {
1966
+ const catalog = catalogForSync(bail);
1967
+ const discovery = await discoverForCli(ctx, bail);
1968
+ onDiscovered?.(discovery);
1969
+ let previous;
1970
+ try { previous = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1971
+ let resolved;
1972
+ // Standalone: no workspace file → the only package request is the kernel's
1973
+ // default, at the catalog's pinned version (decision 25). A catalog that cannot
1974
+ // name it is a PROBLEM of this sync (reported, exit unchanged), not a silent empty lock.
1975
+ const standalone = discovery.standalone === true ? standalonePackages(catalog) : null;
1976
+ const packageSource = standalone ? { packages: standalone.packages } : discovery.workspace;
1977
+ const problems = [...discovery.problems, ...(standalone?.problems ?? [])];
1978
+ try { resolved = await resolvePackages(packageSource, { catalog, lock: previous, remoteOptions: ctx.remoteOptions }); }
2313
1979
  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);
1980
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1981
+ throw e;
1982
+ }
1983
+ let lock = resolved.lock;
1984
+ const approvalNeeded = [];
1985
+ const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
1986
+ for (const id of Object.keys(lock.packages)) {
1987
+ const entry = lock.packages[id];
1988
+ if (entry.approved) continue;
1989
+ let digest, targets;
1990
+ try { ({ digest, targets } = await lockedExecutablesDigest(id, entry, packageSource, catalog, ctx.remoteOptions)); }
1991
+ catch (e) {
1992
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
1993
+ throw e;
2317
1994
  }
2318
- die(e.message || e);
1995
+ if (interactive) {
1996
+ console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
1997
+ for (const t of targets) console.error(` ${t}`);
1998
+ if (targets.length === 0) console.error(" (no commands or hooks — nothing runs unattended)");
1999
+ if (await askYesNo(`approve ${id} ${entry.version}? [y/N] `)) { lock = approvePackage(lock, id, digest); continue; }
2000
+ }
2001
+ approvalNeeded.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets });
2319
2002
  }
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; }
2003
+ let lockFile;
2004
+ try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2005
+ const members = memberRows(discovery);
2006
+ const packages = packageRows(lock);
2007
+ const changes = resolved.changes;
2008
+ const items = workspaceItems(discovery, lock, { includePrivate: true });
2009
+ 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 };
2010
+ return { report, lock, discovery, approvalNeeded, interactive, items, lockFile, problems };
2011
+ }
2012
+
2013
+ /** The human §8 report of a sync (text mode). */
2014
+ function printSyncReport(ctx, synced) {
2015
+ const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
2016
+ const { members, packages, changes } = report;
2017
+ const disabled = new Set(ctx.local.souls?.disabled || []);
2018
+ 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)})`);
2019
+ console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
2020
+ console.log(`packages ${packages.map((p) => {
2021
+ const need = approvalNeeded.find((a) => a.id === p.id);
2022
+ return `${p.id} ${p.version} ✓ (${p.approved ? "approved" : need ? "approval needed" : "unapproved"})`;
2023
+ }).join(" ") || "(none)"}`);
2024
+ const changed = changes.filter((c) => c.to !== null && c.from !== c.to).map((c) => `${c.id} ${c.from ?? "—"} → ${c.to} (@ ${short(c.commit)})`);
2025
+ const removed = changes.filter((c) => c.to === null).map((c) => `${c.id} ${c.from} → removed`);
2026
+ console.log(`changed ${[...changed, ...removed].join(" ") || "(nothing — the lock already described this workspace)"}`);
2027
+ const memberSouls = items.souls.filter((s) => s.kind === "member");
2028
+ const externalSouls = items.souls.filter((s) => s.kind === "external");
2029
+ const privateSouls = memberSouls.filter((s) => s.private);
2030
+ const disabledHere = items.souls.filter((s) => disabled.has(s.name));
2031
+ 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("; ")})` : ""}`);
2032
+ const teams = new Map();
2033
+ for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
2034
+ 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); }
2035
+ 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)"}`);
2036
+ for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
2037
+ if (approvalNeeded.length) {
2038
+ 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).`);
2039
+ } else console.log(`\nlock ${shortPath(lockFile)}`);
2040
+ }
2041
+
2042
+ /** `oats sync [--dir] [--json]` — contract §6. */
2043
+ async function syncCmd() {
2044
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2045
+ const ctx = workspaceContext(bail);
2046
+ const synced = await performSync(ctx, bail);
2047
+ // One envelope (or the §8 report), then the exit status: 2 = the lock is written but approvals
2048
+ // are pending. exitCode (not process.exit) lets stdout drain when it is a pipe.
2049
+ if (JSON_MODE) jsonOk(synced.report); else printSyncReport(ctx, synced);
2050
+ process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2051
+ }
2052
+
2053
+ /** Walk up from dir for oats-workspace.yaml INSIDE a Git checkout → { file, root } | null. */
2054
+ function workspaceCheckoutFrom(dir) {
2055
+ let current = resolve(dir);
2056
+ for (;;) {
2057
+ const candidate = join(current, "oats-workspace.yaml");
2058
+ if (existsSync(candidate)) {
2059
+ // The file is edited in place only when it is TRACKED by the checkout it sits in (an untracked
2060
+ // copy inside some repository is not the shared workspace file).
2061
+ let inCheckout = false;
2062
+ for (let d = current; ; d = dirname(d)) { if (existsSync(join(d, ".git"))) { inCheckout = true; break; } if (dirname(d) === d) break; }
2063
+ if (!inCheckout) return null;
2064
+ const tracked = spawnSync("git", ["-C", current, "ls-files", "--error-unmatch", "--", "oats-workspace.yaml"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
2065
+ return tracked.status === 0 ? { file: candidate, root: current } : null;
2066
+ }
2067
+ const parent = dirname(current);
2068
+ if (parent === current) return null;
2069
+ current = parent;
2070
+ }
2071
+ }
2072
+
2073
+ /** `oats package add <id> <version|git:<repo>@<ref>> | remove <id> [--dir]` — contract §6. */
2074
+ async function packageCmd() {
2075
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2076
+ const sub = args[1];
2077
+ const id = args[2];
2078
+ const value = sub === "add" && typeof args[3] === "string" && !args[3].startsWith("--") ? args[3] : undefined;
2079
+ const usage = "usage: oats package add <id> <version|git:<repo>@<ref>> [--dir <d>] | oats package remove <id> [--dir <d>]";
2080
+ if (!["add", "remove"].includes(sub) || !id || id.startsWith("--")) return bail("E_USAGE", usage);
2081
+ 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 });
2082
+ if (sub === "add") {
2083
+ if (!value || value.startsWith("--")) return bail("E_USAGE", usage);
2084
+ const c = classifyPackageValue(value);
2085
+ 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 });
2086
+ if (c.kind === "git") { try { remoteModule.parseRepoRef(c.repo); } catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); } }
2087
+ }
2088
+ const line = sub === "add" ? ` ${id}: ${YAML.stringify(value).trim()}` : null;
2089
+ const checkout = workspaceCheckoutFrom(dirFlag());
2090
+ if (!checkout) {
2091
+ // Untracked branch (the workspace file is shared through Git, not edited here). A `remove`
2092
+ // still checks the id against the DISCOVERED workspace when this directory realizes one
2093
+ // (oats-local.yaml): removing what is not declared is E_PACKAGE_MISSING, as in the tracked branch.
2094
+ if (sub === "remove") {
2095
+ let ctx = null; try { const found = loadLocal(dirFlag()); ctx = { dir: dirFlag(), localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() }; }
2096
+ catch (e) { if (e?.code !== "E_LOCAL_MISSING") return bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
2097
+ if (ctx) {
2098
+ const discovery = await discoverForCli(ctx, bail);
2099
+ const declared = discovery.standalone === true ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {});
2100
+ 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() });
2101
+ }
3688
2102
  }
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();
2103
+ 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; }
2104
+ 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}`);
2105
+ else console.log(`oats-workspace.yaml is not in this checkout. Remove \`${id}\` from packages: in the workspace repo, then \`oats sync\`.`);
3734
2106
  return;
3735
2107
  }
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 }); }
2108
+ const text = readFileSync(checkout.file, "utf8");
2109
+ let doc;
2110
+ try { doc = YAML.parseDocument(text, { keepSourceTokens: true }); } catch (e) { return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${e.message}`, { path: checkout.file }); }
2111
+ if (doc.errors?.length) return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${doc.errors.map((e) => e.message).join("; ")}`, { path: checkout.file });
2112
+ const root = doc.contents;
2113
+ 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" }] });
2114
+ const packagesNode = root.get("packages", true);
2115
+ if (packagesNode !== undefined && packagesNode !== null && !(YAML.isScalar(packagesNode) && packagesNode.value === null) && !YAML.isMap(packagesNode)) {
2116
+ 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" }] });
2117
+ }
2118
+ const previous = YAML.isMap(packagesNode) ? packagesNode.toJSON() : null;
2119
+ const had = previous && Object.hasOwn(previous, id) ? previous[id] : undefined;
2120
+ if (sub === "add") {
2121
+ if (!YAML.isMap(packagesNode)) root.set("packages", doc.createNode({ [id]: value }));
2122
+ else packagesNode.set(id, value);
3784
2123
  } 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")}`;
2124
+ if (had === undefined) return bail("E_PACKAGE_MISSING", `packages.${id} is not in ${shortPath(checkout.file)}`, { id, path: checkout.file });
2125
+ root.get("packages").delete(id);
2126
+ if (root.get("packages")?.items?.length === 0) root.delete("packages");
2127
+ }
2128
+ const next = doc.toString();
2129
+ const problems = validateWorkspace(YAML.parse(next), { remote: remoteModule });
2130
+ 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 });
2131
+ writeFileAtomic(checkout.file, next);
2132
+ const receipt = { action: sub, id, value: value ?? null, previous: had ?? null, edited: true, file: checkout.file };
2133
+ if (JSON_MODE) { jsonOk(receipt); return; }
2134
+ console.log(sub === "add"
2135
+ ? `${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).`
2136
+ : `Removed packages.${id} (${had}) from ${shortPath(checkout.file)}. Commit it, then \`oats sync\`.`);
3806
2137
  }
3807
2138
 
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();
2139
+ /** `oats workspace status [--dir] [--json]` — contract §6. */
2140
+ async function workspaceCmd() {
2141
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2142
+ if (args[1] !== "status") return bail("E_USAGE", "usage: oats workspace status [--dir <d>] [--json]");
2143
+ const ctx = workspaceContext(bail);
2144
+ const discovery = await discoverForCli(ctx, bail);
2145
+ let lock;
2146
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2147
+ const members = memberRows(discovery);
2148
+ const packages = packageRows(lock);
2149
+ // Standalone (decision 10): there is no workspace file — the declared packages are the
2150
+ // kernel's own default (decision 25) and there are no teams.
2151
+ const standalone = discovery.standalone === true;
2152
+ const declared = Object.keys(standalone ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {})).sort();
2153
+ const locked = new Set(packages.map((p) => p.id));
2154
+ const unsynced = declared.filter((id) => !locked.has(id));
2155
+ const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
2156
+ const approval = { approved: packages.filter((p) => p.approved).map((p) => p.id), needed: packages.filter((p) => !p.approved).map((p) => p.id) };
2157
+ 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 };
2158
+ if (JSON_MODE) { jsonOk(result); return; }
2159
+ console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
2160
+ if (standalone) console.log(` (standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)\n`);
2161
+ console.log("Members:");
2162
+ 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 ?? "?"}` : "—"]));
2163
+ for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
2164
+ console.log("\nPackages:");
2165
+ if (!packages.length) console.log(unsynced.length ? ` (none locked yet — \`oats sync\` resolves ${unsynced.join(", ")})` : " (none)");
2166
+ 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(",")]));
2167
+ if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
2168
+ if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
2169
+ 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(" ")}`);
2170
+ 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}`); }
2171
+ }
2172
+
2173
+ /** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
2174
+ async function itemsCmd(kind) {
2175
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2176
+ const ctx = workspaceContext(bail);
2177
+ const discovery = await discoverForCli(ctx, bail);
2178
+ let lock;
2179
+ try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
2180
+ const items = workspaceItems(discovery, lock)[kind];
2181
+ const standalone = discovery.standalone === true;
2182
+ 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; }
2183
+ console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — standalone: the workspace of ${memberLabel(discovery.key)} cannot be read` : ""}\n`);
2184
+ if (!items.length) console.log(" (none)");
2185
+ else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
2186
+ else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
2187
+ const unsynced = Object.keys(discovery.workspace?.packages || {}).filter((id) => !lock.packages[id]);
2188
+ if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
4027
2189
  }
4028
2190
 
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}` };
2191
+ // ---------- roster: status / spawn / retire / create ----------
2192
+ /** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
2193
+ * the remotes serves every instance; `driftOf` compares each instance's recorded modules to
2194
+ * the members' current state (and package modules to the lock). Offline → { unreachable }.
2195
+ * Returns null when the deployment is not a workspace deployment (no oats-local.yaml). */
2196
+ async function statusDrift(data) {
2197
+ let ctx;
2198
+ try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
2199
+ const hasModules = data.some((a) => (a.instances || []).some((i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length));
2200
+ if (!hasModules) return { drift: new Map(), unreachable: null };
2201
+ const deploymentDir = dirname(ctx.path);
2202
+ let lock = null;
2203
+ try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
2204
+ let discovery;
2205
+ // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
2206
+ // instance is computed against its member's current state, not reported "unreachable".
2207
+ try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
2208
+ catch (e) {
2209
+ const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
2210
+ return { drift: new Map(), unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
4062
2211
  }
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`);
2212
+ const { driftOf } = await import("../lib/materialize.mjs");
2213
+ const drift = new Map();
2214
+ for (const a of data) for (const i of a.instances || []) {
2215
+ if (!i.modules || typeof i.modules !== "object" || !Object.keys(i.modules).length) continue;
2216
+ try { drift.set(i.home ?? `${a.name}/${i.instance}`, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ }
4066
2217
  }
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}` };
2218
+ return { drift, unreachable: null };
4080
2219
  }
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;
2220
+ /** One `modules:` line per module. */
2221
+ function driftLine(row) {
2222
+ const from = row.from || {};
2223
+ const origin = from.kind === "package" ? `package ${from.package} v${from.version}` : memberLabel(row.recorded?.repoKey ?? from.repoKey ?? "?");
2224
+ const base = `modules: ${row.module} from ${origin} @ ${short7(row.recorded?.commit)}`;
2225
+ if (row.status === "moved") return `${base} [${from.kind === "package" ? "package" : "member"} moved since (now @ ${short7(row.current?.commit)})]`;
2226
+ 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"}`}]`;
2227
+ return base;
4116
2228
  }
4117
- const ownScopeCapabilityManifest = (dir, capId) => ownScopeCapabilityManifests(dir)[capId];
2229
+ const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
4118
2230
 
4119
- // ---------- roster: status / spawn / retire / create ----------
4120
- function status() {
2231
+ async function status() {
4121
2232
  if (args.includes("--team")) return statusTeam();
4122
- const root = ensureRoot(dirFlag());
2233
+ let root;
2234
+ try { root = ensureRoot(dirFlag()); }
2235
+ 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
2236
  const data = listInstances(root);
4124
- if (args.includes("--json")) { console.log(JSON.stringify({ root, agents: data }, null, 2)); return; }
2237
+ const ws = await statusDrift(data);
2238
+ const verbose = args.includes("--verbose");
2239
+ if (args.includes("--json")) {
2240
+ 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 } : {}) })); }
2241
+ console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
2242
+ }
4125
2243
  console.log(`oats status — agents root ${shortPath(root)}\n`);
2244
+ if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
4126
2245
  if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
4127
2246
  for (const a of data) {
4128
2247
  console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
4129
2248
  if (a.description) console.log(` ${a.description}`);
4130
2249
  for (const i of a.instances) {
4131
2250
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2251
+ const rows = ws?.drift.get(i.home ?? `${a.name}/${i.instance}`) || [];
2252
+ for (const r of rows) if (verbose || r.status !== "current") console.log(` ${driftLine(r)}`);
4132
2253
  }
4133
2254
  for (const f of a.retireFailures || []) {
4134
2255
  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 +2280,7 @@ function statusTeam() {
4159
2280
  }
4160
2281
  }
4161
2282
 
4162
- function spawnCmd() {
2283
+ async function spawnCmd() {
4163
2284
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
4164
2285
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
4165
2286
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
@@ -4195,6 +2316,52 @@ function spawnCmd() {
4195
2316
  root = hit.root;
4196
2317
  }
4197
2318
  let agent = findAgent(root, name);
2319
+ // Workspace model: with an oats-local.yaml the soul is ALWAYS discovered over the
2320
+ // remotes and resolved (member = latest state, package = locked+approved) — never
2321
+ // "whatever <agents-root>/<name>/soul/ happens to hold": that copy is a per-commit
2322
+ // cache (ensureWorkspaceSoul refreshes it when the member moved), so a second
2323
+ // spawn sees the member's CURRENT soul, not the first spawn's. A preview runs the
2324
+ // same read-only discovery+resolution; the fetched soul copy it may leave under
2325
+ // <agents-root>/<name>/soul/ is not an instance (reported as soulFetched).
2326
+ const providerPairs = [];
2327
+ 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; }
2328
+ let wsPrepared, soulFetched = false, wsSoulUnknown = null;
2329
+ let hasLocal = true;
2330
+ {
2331
+ try { loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") hasLocal = false; else bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
2332
+ if (hasLocal) {
2333
+ let discovery = null;
2334
+ try {
2335
+ const { prepareInstance, ensureWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
2336
+ const remoteOptions = remoteOptionsFromEnv();
2337
+ const { local } = loadLocal(dirFlag());
2338
+ discovery = await discoverOrStandalone(local, { remoteOptions });
2339
+ wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
2340
+ const soulName = wsPrepared.soulEntry.name;
2341
+ const stampFile = join(root, soulName, ".oats-soul-source.json");
2342
+ const stampBefore = (() => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } })();
2343
+ const soulDir = await ensureWorkspaceSoul(wsPrepared, root);
2344
+ soulFetched = !stampBefore || stampBefore.commit !== wsPrepared.soulEntry.commit || stampBefore.repoKey !== wsPrepared.soulEntry.repoKey;
2345
+ if (!agent || soulFetched || agent._dir !== dirname(soulDir)) agent = findAgent(root, soulName);
2346
+ if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
2347
+ 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" : ""})`);
2348
+ } catch (e) {
2349
+ // Standalone (decisions 10/25): the ONLY package request is the kernel's own
2350
+ // default; when the catalog cannot name it, say so instead of "add it to packages:"
2351
+ // (there is no workspace file to add it to).
2352
+ if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
2353
+ let file = process.env.OATS_PACKAGE_CATALOG || null; try { file = describeOfficialCatalog().catalog.file; } catch { /* keep the env value */ }
2354
+ 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 });
2355
+ }
2356
+ // Not a workspace soul: a capability-defined agent (a module's `agents:`
2357
+ // soul, resolved below from a materialized copy) or a local-only soul
2358
+ // (--instructions-file/--def-file) may still answer to this name.
2359
+ if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
2360
+ else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
2361
+ else throw e;
2362
+ }
2363
+ } else if (providerPairs.length) bail("E_BAD_ARGS", "--provider needs a workspace deployment (oats-local.yaml); this directory has none");
2364
+ }
4198
2365
  if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
4199
2366
  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
2367
  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 +2375,33 @@ function spawnCmd() {
4208
2375
  note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
4209
2376
  }
4210
2377
  }
2378
+ if (!agent && !instrFile && !defFile && hasLocal) {
2379
+ // Workspace model: the agent is declared by a capability some INSTANCE already
2380
+ // materialized (the --parent home first, then any home under this root) —
2381
+ // OKF's memory-harvest worker spawned by a knowledge source, for example.
2382
+ const anchorName = flag("parent") || flag("relative-to");
2383
+ const anchorHome = anchorName ? (findInstanceHome(root, String(anchorName)) ?? null) : null;
2384
+ let modAgent;
2385
+ try { modAgent = findModuleCapabilityAgent(root, name, { anchorHome }); }
2386
+ catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
2387
+ if (modAgent) {
2388
+ agent = modAgent;
2389
+ note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home locally)`);
2390
+ } else {
2391
+ // No instance carries it: resolve from the deployment's LOCK — an approved
2392
+ // package whose capability declares agents/<name> is fetched into the
2393
+ // deployment's module store and read from there.
2394
+ try {
2395
+ const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
2396
+ const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
2397
+ if (hit) {
2398
+ agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
2399
+ 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)`);
2400
+ }
2401
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2402
+ }
2403
+ }
2404
+ if (!agent && wsSoulUnknown && !instrFile && !defFile) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
4211
2405
  if (!agent && !instrFile && !defFile) {
4212
2406
  // Cross-repo lookup: the soul may live in a sibling repo of the team scope.
4213
2407
  // Unique match wins; the instance homes with its owning repo's agents root.
@@ -4303,11 +2497,26 @@ function spawnCmd() {
4303
2497
  }
4304
2498
  if (wake && (typeof wake.message !== "string" || !wake.message.trim())) bail("E_SCHEDULE_INVALID", "wake message: non-empty text is required");
4305
2499
  } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2500
+ // Workspace model: when this deployment has an oats-local.yaml, the soul's
2501
+ // capabilities are resolved over the workspace's remotes (member = latest,
2502
+ // package = locked+approved) and copied whole into the new home. Without one
2503
+ // (a bare agents root, tests) the classic soul-directory spawn proceeds.
2504
+ let prepared;
2505
+ if (wsPrepared) {
2506
+ try {
2507
+ const { toCapabilityRows, modulesPreview } = await import("../lib/instance-resolution.mjs");
2508
+ prepared = wsPrepared;
2509
+ prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
2510
+ prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
2511
+ prepared.toCapabilityRows = toCapabilityRows;
2512
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
2513
+ }
4306
2514
  let r;
4307
2515
  try {
4308
2516
  if (args.includes("--allow-child-spawns") && args.includes("--no-child-spawns")) bail("E_BAD_ARGS", "--allow-child-spawns and --no-child-spawns contradict");
4309
2517
  if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
4310
- r = spawnInstance(root, agent, {
2518
+ { const spawnOpts = {
2519
+ prepared,
4311
2520
  purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
4312
2521
  ...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
4313
2522
  // Directory execution uses deployment configuration, not an ambient Git
@@ -4326,8 +2535,16 @@ function spawnCmd() {
4326
2535
  ...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
4327
2536
  // K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
4328
2537
  ...(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; }
2538
+ };
2539
+ r = prepared ? await spawnInstanceAsync(root, agent, spawnOpts) : spawnInstance(root, agent, spawnOpts); }
2540
+ if (args.includes("--preview")) {
2541
+ // A workspace preview may have fetched the soul's SOURCE under <agents-root>/<name>/soul/
2542
+ // (a per-commit cache, not an instance): the result says so.
2543
+ if (prepared) r.soulFetched = soulFetched;
2544
+ if (JSON_MODE) { jsonOk(r); return; }
2545
+ 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)` : ""}`);
2546
+ return;
2547
+ }
4331
2548
  } catch (e) {
4332
2549
  // A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
4333
2550
  // (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
@@ -4604,143 +2821,141 @@ async function paneCmd() {
4604
2821
  die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
4605
2822
  }
4606
2823
 
4607
- function onboardCmd() {
4608
- const fail = (code, message, details) => JSON_MODE ? jsonFail(code, message, details) : die(message);
4609
- const values = new Map();
2824
+ /** `oats onboard [<dir>] --workspace <repo ref> [--json]` — workspace model v2 (decision 9).
2825
+ *
2826
+ * Realizes a workspace on this machine in the taught `<name>-workspace/` layout
2827
+ * (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
2828
+ * `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
2829
+ * instance homes), then runs exactly the `oats sync` path — discover over the
2830
+ * remotes, confirm membership, resolve `packages:`, approve (TTY) or list what
2831
+ * needs approval (exit 2), write `oats-lock.json`. Nothing is installed, no soul
2832
+ * is created, nothing is spawned, no `oats-config.yaml` is written: the member
2833
+ * clones and the setup expert are the operator's next steps, printed here. */
2834
+ async function onboardCmd() {
2835
+ const bail = (code, message, details) => (JSON_MODE ? jsonFail(code, message, details) : die(message));
2836
+ const usage = "usage: oats onboard [<dir>] --workspace <repo ref> [--json] (or --dir <dir>)";
2837
+ let positional, workspaceRef, dirValue;
4610
2838
  for (let i = 1; i < args.length; i++) {
4611
2839
  const arg = args[i];
4612
- if (["--json", "--force-existing"].includes(arg)) { values.set(arg.slice(2), true); continue; }
4613
- if (!["--dir", "--workspace"].includes(arg) || values.has(arg.slice(2)) || !args[i + 1] || args[i + 1].startsWith("--")) {
4614
- fail("E_BAD_ARGS", "usage: oats onboard [--dir <deployment>] [--workspace <git:source[@revision]>] [--force-existing] [--json]");
4615
- }
4616
- values.set(arg.slice(2), args[++i]);
4617
- }
4618
- dropAmbientRoot();
4619
- let deployment, root, acquired, created, configFile, configBefore, configWritten;
2840
+ if (arg === "--json") continue;
2841
+ if (arg === "--dir" || arg === "--workspace") {
2842
+ const value = args[i + 1];
2843
+ if (value === undefined || value.startsWith("--")) return bail("E_BAD_ARGS", `--${arg.slice(2)} needs a value\n${usage}`);
2844
+ if (arg === "--dir") { if (dirValue !== undefined) return bail("E_BAD_ARGS", usage); dirValue = value; }
2845
+ else { if (workspaceRef !== undefined) return bail("E_BAD_ARGS", usage); workspaceRef = value; }
2846
+ i++; continue;
2847
+ }
2848
+ if (arg.startsWith("--")) return bail("E_BAD_ARGS", `unknown flag ${arg}\n${usage}`);
2849
+ if (positional !== undefined) return bail("E_BAD_ARGS", usage);
2850
+ positional = arg;
2851
+ }
2852
+ if (positional !== undefined && dirValue !== undefined) return bail("E_BAD_ARGS", `give the deployment directory once, as <dir> or --dir\n${usage}`);
2853
+ if (!workspaceRef || !workspaceRef.trim()) return bail("E_BAD_ARGS", `--workspace <repo ref> is required (the repository hosting oats-workspace.yaml)\n${usage}`);
2854
+ workspaceRef = workspaceRef.trim();
2855
+ // The ref must be one lib/remote.mjs understands BEFORE anything is written.
2856
+ try { remoteModule.parseRepoRef(workspaceRef); }
2857
+ catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); }
2858
+
2859
+ const dir = resolve(positional ?? dirValue ?? process.cwd());
2860
+ const localFile = join(dir, "oats-local.yaml");
2861
+ // (1) Refuse to onboard twice: THIS directory's oats-local.yaml is the mark (an enclosing
2862
+ // deployment's file does not count — a nested directory is a different deployment).
2863
+ let existing = null;
2864
+ try { existing = lstatSync(localFile); } catch (e) { if (e.code !== "ENOENT") return bail("E_ONBOARD_FAILED", `cannot inspect ${localFile}: ${e.message}`); }
2865
+ 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 });
2866
+ if (existsSync(dir) && !lstatSync(dir).isDirectory()) return bail("E_ONBOARD_FAILED", `${shortPath(dir)} exists and is not a directory`, { dir });
2867
+
2868
+ // (2) The two files/dirs onboarding owns. Written atomically; rolled back if the sync
2869
+ // that follows cannot even read the workspace (a typo'd ref must not leave a
2870
+ // half-onboarded directory that looks finished).
2871
+ const created = [];
4620
2872
  try {
4621
- // Like create: an existing enclosing roster wins, otherwise bootstrap at
4622
- // the enclosing Git root or explicit directory. Canonicalize existing parents.
4623
- const requested = resolve(values.get("dir") || process.cwd()), missing = [];
4624
- let parent = requested;
4625
- while (!existsSync(parent)) { missing.unshift(basename(parent)); parent = dirname(parent); }
4626
- const start = join(realpathSync(parent), ...missing);
4627
- root = findRoot(start) || join(defaultRepo(start) || start, "agents");
4628
- deployment = dirname(root);
4629
- assertNoSymlinkedParents(deployment, root, "onboard agents root");
4630
- assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4631
- const assertSetupAbsent = () => {
4632
- 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)]) {
4633
- let present = false;
4634
- try { lstatSync(candidate); present = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
4635
- 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" });
4636
- }
4637
- };
4638
- assertSetupAbsent();
4639
- const agents = listAgents(root);
4640
- 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" });
4641
- if (findAgent(root, SETUP_EXPERT)) throw Object.assign(new Error("oats-setup-expert already exists; it will not be overwritten"), { code: "E_AGENT_EXISTS" });
4642
- const edition = loadSetupExpertEdition(values.get("workspace"));
4643
- for (const key of ["description", "runtime", "model"]) if (edition.declaration[key] !== undefined) assertSafeConfigValue(edition.declaration[key], `setup edition ${key}`);
4644
- const catalog = officialPackageCatalog();
4645
- // Catalog precedence: an explicit OATS_PACKAGE_CATALOG override, else the entry the WORKSPACE
4646
- // publishes at the edition's revision, else the kernel's bundled snapshot. The bundled copy lags
4647
- // every oats.framework release cut after this kernel's tag, and a second operator has no main
4648
- // checkout to point an override at (0.24.5; second-operator finding).
4649
- const bundledEntry = catalog["oats.framework"];
4650
- const entry = process.env.OATS_PACKAGE_CATALOG ? bundledEntry : (edition.catalogEntry ?? bundledEntry);
4651
- const catalogOrigin = process.env.OATS_PACKAGE_CATALOG ? "override" : edition.catalogEntry ? "workspace" : "bundled";
4652
- if (!Object.hasOwn(catalog, "oats.framework") || !entry?.url || !entry.ref
4653
- || SETUP_CAPABILITIES.some(id => { const m = officialCapabilityPackage(id); return !m.available || m.package !== "oats.framework" || m.migratedCapability !== id; })) {
4654
- throw Object.assign(new Error("official oats.framework with core/setup aliases and a published revision is required"), { code: "needs-configuration" });
4655
- }
4656
- const catalogSource = parsePortableSource(`git:${entry.url}@${entry.ref}#${entry.path ?? DEFAULT_PACKAGE_PATH}`);
4657
- const file = join(deployment, "oats-config.yaml");
4658
- if (existsSync(file) && !lstatSync(file).isFile()) throw Object.assign(new Error("onboard will not replace a non-regular deployment configuration"), { code: "E_CONFIG_BROKEN" });
4659
- const before = existsSync(file) ? readFileSync(file, "utf8") : null;
4660
- configFile = file; configBefore = before;
4661
- const caps = readCapabilitiesModel(file), previous = resolveOatsConfig(deployment, SETUP_EXPERT);
4662
- // Exclusions are for the NEW soul only. Never turn off an existing root's
4663
- // global provider/layer just to make bootstrap work under --force-existing.
4664
- for (const cap of previous.capabilities) {
4665
- if (SETUP_CAPABILITIES.includes(cap.id)) continue;
4666
- let target;
4667
- if (cap.layer) {
4668
- const existing = caps.layers[cap.layer];
4669
- 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" });
4670
- target = caps.layers[cap.layer] ||= { capability: cap.id };
4671
- } else target = caps.additive[cap.id] ||= {};
4672
- target.souls = { ...target.souls, [SETUP_EXPERT]: false };
4673
- }
4674
- if (before === null && !agents.length) for (const layer of LAYERS) caps.layers[layer] ??= "none";
4675
- for (const id of SETUP_CAPABILITIES) {
4676
- const target = caps.additive[id] ||= {};
4677
- if (target.from && target.from !== "installed") throw Object.assign(new Error(`${id} already selects another provenance; choose a fresh deployment`), { code: "needs-configuration" });
4678
- target.from = "installed"; target.souls = { ...target.souls, [SETUP_EXPERT]: true };
4679
- }
4680
- const text = replaceCapabilitiesBlock(before ?? `name: ${scaffoldConfigName(deployment)}\n`, caps);
4681
- mkdirSync(deployment, { recursive: true });
4682
- acquired = acquirePackage(deployment, "oats.framework", { expectPackage: "oats.framework",
4683
- catalog(id, selector) {
4684
- const selected = id === "oats.framework" ? entry : (Object.hasOwn(catalog, id) ? catalog[id] : null);
4685
- return selected?.url ? { url: selected.url, ref: selector || selected.ref, path: selected.path } : undefined;
4686
- },
4687
- assertCommittable(plan) {
4688
- const pkg = plan.packages.find(p => p.package === "oats.framework");
4689
- if (edition.packageIntegrity && pkg?.integrity !== edition.packageIntegrity) {
4690
- 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)" : "";
4691
- throw Object.assign(new Error(`selected edition's same-repository package differs from the official acquisition; align the reviewed source and catalog explicitly${lag}`),
4692
- { code: "integrity-drift", details: { catalogOrigin, catalogRef: entry.ref, acquiredIntegrity: pkg?.integrity ?? null, editionPackageIntegrity: edition.packageIntegrity } });
4693
- }
4694
- for (const id of SETUP_CAPABILITIES) {
4695
- const cap = plan.capabilities.find(c => c.capability === id);
4696
- if (!cap || cap.package !== "oats.framework" || cap.layer || Object.values(cap.executableSurface || {}).some(value => Array.isArray(value) && value.length)) {
4697
- throw Object.assign(new Error(`setup bootstrap needs resources-only ${id}; executable surfaces require a separate explicit approval path`), { code: "approval-required" });
4698
- }
4699
- }
4700
- } });
4701
- 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" });
4702
- writeFileAtomic(file, text); configWritten = text;
4703
- const selected = resolveOatsConfig(deployment, SETUP_EXPERT);
4704
- if (selected.capabilities.length !== 2 || SETUP_CAPABILITIES.some(id => !selected.capabilities.some(c => c.id === id))) {
4705
- throw Object.assign(new Error("setup expert's effective configuration contains other capabilities; no soul or hook was created"), { code: "needs-configuration" });
4706
- }
4707
- // Required capabilities were checked resources-only before acquisition: no
4708
- // unrelated or executable soul-scaffold hooks can run during local creation.
4709
- assertNoSymlinkedParents(deployment, root, "onboard agents root");
4710
- assertNoSymlinkedParents(deployment, join(deployment, "local-agents", SETUP_EXPERT), "local setup soul");
4711
- assertSetupAbsent();
4712
- mkdirSync(root, { recursive: true });
4713
- created = coreCreateAgent(root, { name: SETUP_EXPERT, local: true, oatsCore: false, repo: deployment, work: "directory",
4714
- runtime: edition.declaration.runtime, model: edition.declaration.model, yolo: false,
4715
- description: edition.declaration.description, instructions: edition.instructions });
4716
- const pkg = acquired.installed.find(p => p.package === "oats.framework");
4717
- const source = parsePortableSource(`git:${catalogSource.url}@${pkg.commit}#${pkg.path}`);
4718
- const requires = { capabilities: Object.fromEntries(SETUP_CAPABILITIES.map(id => [id, { source: `${source.source}#${source.path}` }])) };
4719
- const soulFile = join(created.soul, "soul.yaml");
4720
- writeFileAtomic(soulFile, readFileSync(soulFile, "utf8") + `requires: ${JSON.stringify(requires)}\ndefaults: ${JSON.stringify(edition.declaration.defaults)}\n`
4721
- + `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`);
4722
- const agent = findAgent(root, SETUP_EXPERT), composition = composeInstanceAgentsMd(created.soul, deployment, SETUP_EXPERT, "directory", "local");
4723
- planInstanceResources({ resolved: composition.resolved, soulDir: created.soul, agent, contextDir: deployment, composition });
4724
- 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."];
4725
- const result = { mode: "classic", captured: false, deployment, agentsRoot: root, ...created, source: edition.source, catalog: { origin: catalogOrigin, ref: entry.ref },
4726
- package: { id: pkg.package, version: pkg.version, commit: pkg.commit, path: pkg.path }, lockFile: acquired.lockFile,
4727
- capabilities: [...SETUP_CAPABILITIES], launched: false, next: { argv, command: argv.map(shellQuote).join(" ") } };
4728
- if (JSON_MODE) jsonOk(result);
4729
- 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}`); }
4730
- } catch (error) {
4731
- // Roll back only configuration bytes still exactly owned by this attempt.
4732
- // Acquired artifacts/locks and any incomplete new soul remain visible evidence.
4733
- let configRestored = false;
4734
- if (configWritten !== undefined) {
4735
- try {
4736
- if (lstatSync(configFile).isFile() && readFileSync(configFile, "utf8") === configWritten) {
4737
- if (configBefore === null) rmSync(configFile); else writeFileAtomic(configFile, configBefore);
4738
- configRestored = true;
4739
- }
4740
- } catch { /* never erase another writer's change or hide a failed rollback */ }
2873
+ if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); created.push(dir); }
2874
+ const local = { schemaVersion: 2, workspace: workspaceRef };
2875
+ writeFileAtomic(localFile, YAML.stringify(local));
2876
+ created.push(localFile);
2877
+ const agentsDir = join(dir, "agents");
2878
+ if (!existsSync(agentsDir)) { mkdirSync(agentsDir); created.push(agentsDir); }
2879
+ } catch (e) {
2880
+ rollback();
2881
+ return bail(e.code && String(e.code).startsWith("E_") ? e.code : "E_ONBOARD_FAILED", `cannot write ${shortPath(dir)}: ${e.message}`, { dir });
2882
+ }
2883
+ /** Only what THIS onboard created, only while still exactly ours: our local file, an EMPTY
2884
+ * agents/, an otherwise-empty <dir> (rmdirSync refuses a non-empty directory — evidence stays). */
2885
+ function rollback() {
2886
+ for (const p of [...created].reverse()) {
2887
+ try { if (p === localFile) rmSync(p, { force: true }); else rmdirSync(p); }
2888
+ catch { /* leave evidence rather than erase another writer's work */ }
4741
2889
  }
4742
- 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 : {}) });
4743
2890
  }
2891
+ const bailRollback = (code, message, details) => { rollback(); return bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, rolledBack: true }); };
2892
+
2893
+ // (3) Exactly the `oats sync` body over the deployment just written. A failure while
2894
+ // DISCOVERING (unreadable remote, not a workspace host) rolls the two files back; once the
2895
+ // workspace has been read, the files stay (a lock may already be written).
2896
+ let ctx;
2897
+ try {
2898
+ const found = loadLocal(dir);
2899
+ ctx = { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
2900
+ } catch (e) { return bailRollback(e.code || "E_LOCAL_MISSING", e.message, e.details); }
2901
+ let discovered = false;
2902
+ const syncBail = (code, message, details) => (discovered
2903
+ ? bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, local: localFile })
2904
+ : bailRollback(code, message, details));
2905
+ const synced = await performSync(ctx, syncBail, { onDiscovered: () => { discovered = true; } });
2906
+
2907
+ // (4) The taught layout as next steps (design doc §4), and the envelope.
2908
+ const standalone = synced.discovery.standalone === true;
2909
+ const members = synced.report.members;
2910
+ // The setup expert is suggested only when THIS workspace lists a soul by that name (a
2911
+ // confirmed member's or, standalone, the repo's own); otherwise any listed soul is spawnable.
2912
+ const soulNames = synced.items.souls.map((s) => s.name);
2913
+ const setupExpert = soulNames.includes("oats-setup-expert");
2914
+ const spawnHint = setupExpert ? `oats spawn oats-setup-expert --dir ${shortPath(dir)}` : null;
2915
+ const anySoulHint = `spawn any listed soul: oats spawn <soul> --dir ${shortPath(dir)}${soulNames.length ? ` (e.g. ${soulNames.slice(0, 3).join(", ")})` : ""}`;
2916
+ // A member's clone goes beside oats-local.yaml under its repo name; `agents/` is the instance
2917
+ // homes, so a member called "agents" is cloned as `agents-repo/` (design doc §4).
2918
+ const cloneDirOf = (m) => join(dir, m.name === "agents" ? "agents-repo" : m.name);
2919
+ 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) }));
2920
+ // Decision 26: the host publishes the member list to whoever can read it. When the host is
2921
+ // itself a member (the common `agents` shape) that is fine for an all-private or all-public
2922
+ // organisation; a mixed one needs a private host that is NOT a public member. The kernel
2923
+ // cannot see forge visibility, so it states the rule rather than judging.
2924
+ const hostIsMember = members.some((m) => m.key === synced.discovery.key);
2925
+ 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)." };
2926
+ 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) } };
2927
+ if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
2928
+
2929
+ 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`);
2930
+ printSyncReport(ctx, synced);
2931
+ console.log(`
2932
+ Layout (the taught convention — the kernel finds clones through oats-local.yaml, so any layout works):
2933
+ ${shortPath(dir)}/
2934
+ ├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
2935
+ ├── oats-lock.json exact commit + integrity + per-version executable approval per package
2936
+ ├── agents/ instance homes, each self-contained
2937
+ └── <member>/ clones of the members you will work IN (only those)
2938
+
2939
+ Next:
2940
+ 1. Clone the members you will work IN beside oats-local.yaml (discovery and resolution run over the
2941
+ 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)"}
2942
+ A clone elsewhere is fine: point at it in oats-local.yaml under clones: { <repo key>: <abs path> }.
2943
+ 2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
2944
+ names every member, so if any member is private the host must be a private repo that is not
2945
+ a public member; public contributors then get the standalone case (from: here + oats.core).
2946
+ 3. ${setupExpert ? "Spawn the setup expert to guide the rest (souls, teams, provider settings, approvals):" : "No soul named oats-setup-expert is listed here —"}
2947
+ ${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(", ")})` : ""}`);
2948
+ process.exitCode = synced.approvalNeeded.length ? 2 : 0;
2949
+ }
2950
+
2951
+ /** The clone URL of a member row: what the remote observed (from the workspace's members: refs;
2952
+ * standalone, the one repo oats-local.yaml named). */
2953
+ function memberUrlOf(discovery, key) {
2954
+ for (const ref of discovery.workspace?.members || []) {
2955
+ try { const parsed = remoteModule.parseRepoRef(ref); if (parsed.key === key) return parsed.url; } catch { /* schema already validated */ }
2956
+ }
2957
+ if (discovery.standalone === true && discovery.key === key) return discovery.url ?? null;
2958
+ return null;
4744
2959
  }
4745
2960
 
4746
2961
  function createCmd() {
@@ -4773,7 +2988,7 @@ function createCmd() {
4773
2988
  // step here, not only at the refusal.
4774
2989
  const declared = r.declaredCapabilities || [];
4775
2990
  const inactive = declared.filter((id) => !(resolveOatsConfig(workspaceOf(root), name).capabilities || []).some((c) => c.id === id));
4776
- 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(" && ")}` }] : [];
2991
+ 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)` }] : [];
4777
2992
  const notes = [...(r.notes || []), ...next];
4778
2993
  if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(notes.length ? { notes } : {}), ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
4779
2994
  for (const information of notes) console.error(`[${information.code}] ${information.message}`);
@@ -4787,18 +3002,27 @@ function createCmd() {
4787
3002
  * oats <namespace> <command> [args…] — run a command an active capability
4788
3003
  * declares in its manifest (`commands: { name: "script args" }`).
4789
3004
  * Kernel subcommands take precedence over capability namespaces.
3005
+ *
3006
+ * Three contexts, one contract (OATS_CAPABILITY / OATS_SETTINGS / OATS_CLI_BIN):
3007
+ * - inside an instance home: the home's materialized modules (instance.json.modules);
3008
+ * - from a v2 DEPLOYMENT (oats-local.yaml in reach, no home): operator-level
3009
+ * dispatch — resolve exactly as `oats spawn --soul <x>` would, fetch the
3010
+ * namespace's capability into <deployment>/.oats/modules/<cap>@<commit12>/
3011
+ * and run THAT copy with the soul's merged payload (lib/operator-dispatch.mjs;
3012
+ * contracts doc, "Post-0.25.0 clarifications");
3013
+ * - otherwise the classic config chain.
4790
3014
  */
4791
- function capabilityCommand() {
3015
+ async function capabilityCommand() {
4792
3016
  // JSON-aware boundary: in --json mode every dispatch failure — inactive or
4793
3017
  // untrusted capability, duplicate namespace, unknown subcommand, broken
4794
3018
  // metadata/manifests, malformed command values — must still emit exactly
4795
3019
  // one envelope object on stdout. The WHOLE dispatcher runs inside the
4796
3020
  // boundary; only "no namespace matched" escapes (returns false to the help
4797
3021
  // fallthrough).
4798
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3022
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
4799
3023
  const NOT_DISPATCHED = Symbol("not-dispatched");
4800
3024
  let outcome;
4801
- try { outcome = dispatch(); }
3025
+ try { outcome = await dispatch(); }
4802
3026
  catch (e) {
4803
3027
  // Unexpected throw from discovery/trust/decoding: keep the envelope contract.
4804
3028
  bail("E_CAPABILITY_BROKEN", e.message || e);
@@ -4806,7 +3030,23 @@ function capabilityCommand() {
4806
3030
  }
4807
3031
  return outcome !== NOT_DISPATCHED;
4808
3032
 
4809
- function dispatch() {
3033
+ /** Operator-level dispatch from a deployment directory (no instance home). */
3034
+ async function operatorDispatch() {
3035
+ let hit;
3036
+ try {
3037
+ const { resolveOperatorDispatch } = await import("../lib/operator-dispatch.mjs");
3038
+ let catalog = null; try { catalog = officialPackageCatalog(); } catch { /* the lock carries url for catalog packages */ }
3039
+ hit = await resolveOperatorDispatch(process.cwd(), cmd, flag("soul"), { remoteOptions: remoteOptionsFromEnv(), catalog });
3040
+ } catch (e) {
3041
+ if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details);
3042
+ throw e;
3043
+ }
3044
+ if (!hit) return NOT_DISPATCHED;
3045
+ const teamCtx = hit.soul?.team ? { name: hit.soul.team } : undefined;
3046
+ return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, hit.settings, teamCtx, hit.ensureTree);
3047
+ }
3048
+
3049
+ async function dispatch() {
4810
3050
  let activeIds;
4811
3051
  let context = process.cwd();
4812
3052
  let teamCtx;
@@ -4817,9 +3057,12 @@ function capabilityCommand() {
4817
3057
  // manifests. Null-prototype because the dispatcher indexes it with the
4818
3058
  // namespace the operator typed on the command line.
4819
3059
  let capSettings = Object.create(null);
3060
+ let instanceModules = false;
3061
+ let deployment = null;
4820
3062
  try {
4821
3063
  if (metaFile && existsSync(metaFile)) {
4822
3064
  const meta = JSON.parse(readFileSync(metaFile, "utf8"));
3065
+ instanceModules = !!(meta.modules && typeof meta.modules === "object");
4823
3066
  activeIds = (meta.capabilities || []).map((c) => c.id);
4824
3067
  for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
4825
3068
  context = meta.repo || context;
@@ -4827,19 +3070,36 @@ function capabilityCommand() {
4827
3070
  // spawned before a team: block was declared have no snapshot.
4828
3071
  teamCtx = meta.team || resolveOatsConfig(context).team;
4829
3072
  } else {
4830
- const resolved = resolveOatsConfig(context, flag("soul"));
4831
- activeIds = resolved.capabilities.map((c) => c.id);
4832
- for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
4833
- teamCtx = resolved.team;
3073
+ // Not inside a home: a v2 deployment (oats-local.yaml in reach) resolves
3074
+ // through the workspace, exactly as a spawn of --soul would (below).
3075
+ try { const { deploymentOf } = await import("../lib/operator-dispatch.mjs"); deployment = deploymentOf(context); }
3076
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
3077
+ if (!deployment) {
3078
+ const resolved = resolveOatsConfig(context, flag("soul"));
3079
+ activeIds = resolved.capabilities.map((c) => c.id);
3080
+ for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
3081
+ teamCtx = resolved.team;
3082
+ }
4834
3083
  }
4835
3084
  } catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
4836
- const mans = Object.values(capabilityManifests(context)).filter((m) => m.command === cmd && m.commands);
3085
+ if (deployment) return operatorDispatch();
3086
+ // Workspace model: an instance's own materialized modules are the command
3087
+ // namespaces available to it (instance.json.modules → <home>/.oats/modules).
3088
+ const mans = Object.values(capabilityManifests(instanceModules ? instanceHome : context)).filter((m) => m.command === cmd && m.commands);
4837
3089
  if (!mans.length) return NOT_DISPATCHED;
4838
3090
  if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
4839
3091
  const m = mans[0];
4840
3092
  if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
4841
3093
  const trust = capabilityTrust(m, context);
4842
3094
  if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
3095
+ return runManifestCommand(m, capSettings[m.capability] || {}, teamCtx, () => m._dir);
3096
+ }
3097
+
3098
+ /** Help / unknown-command / spec validation / exec — shared by every context.
3099
+ * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
3100
+ * resolves the directory holding the executable — the operator branch fetches
3101
+ * the module tree only when a command is actually going to run). */
3102
+ async function runManifestCommand(m, settings, teamCtx, ensureDir) {
4843
3103
  const sub = args[1];
4844
3104
  const cmds = Object.keys(m.commands);
4845
3105
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -4865,17 +3125,22 @@ function capabilityCommand() {
4865
3125
  const spec = m.commands[sub];
4866
3126
  if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: manifest command must be a non-empty string (got ${JSON.stringify(spec)})`);
4867
3127
  const [script, ...rest] = spec.trim().split(/\s+/);
3128
+ let dir;
3129
+ try { dir = await ensureDir(); }
3130
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
3131
+ const withDir = { ...m, _dir: dir };
4868
3132
  let abs;
4869
- try { abs = capabilityExecutablePath(m, script); }
3133
+ try { abs = capabilityExecutablePath(withDir, script); }
4870
3134
  catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
4871
- if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(m._dir, script)})`);
3135
+ if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(dir, script)})`);
4872
3136
  const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
4873
3137
  ...process.env, OATS_CAPABILITY: m.capability,
4874
3138
  // Package-runtime boundary: dispatched commands receive the active
4875
- // capability's EFFECTIVE settings (instance snapshot or resolved context),
4876
- // same contract as lifecycle hooks — capabilities read their settings
4877
- // here instead of importing the kernel resolver.
4878
- OATS_SETTINGS: JSON.stringify(capSettings[m.capability] || {}),
3139
+ // capability's EFFECTIVE settings (instance snapshot, resolved context, or
3140
+ // the soul's merged payload on operator-level dispatch), same contract as
3141
+ // lifecycle hooks — capabilities read their settings here instead of
3142
+ // importing the kernel resolver.
3143
+ OATS_SETTINGS: JSON.stringify(settings || {}),
4879
3144
  // PATH is not a trusted runtime boundary (maintainer finding 1): pass the
4880
3145
  // canonical absolute executable of THIS CLI; official consumers execFile
4881
3146
  // it directly and never resolve `oats` from PATH or a shell.
@@ -4935,59 +3200,6 @@ function typeCmd() {
4935
3200
  console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
4936
3201
  }
4937
3202
 
4938
- // ---------- injection eject ----------
4939
- function injectCmd() {
4940
- const sub = args[1];
4941
- const target = args[2];
4942
- if (sub !== "eject" || !target || target.startsWith("--")) die("usage: oats inject eject <capability-id|oats> [--dir <dir>]");
4943
- const dir = dirFlag();
4944
- const file = join(dir, "oats-config.yaml");
4945
- if (!existsSync(file)) die(`no oats-config.yaml at ${shortPath(dir)} — run oats init first`);
4946
- 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)");
4947
- const isWorkMode = false;
4948
- const isKernel = target === "oats";
4949
- const src = isKernel ? packagedInject("oats", dir) : isWorkMode ? packagedInject(`work-${target}`, dir) : packagedInject(target, dir);
4950
- if (!src) die(`no packaged default injection found for "${target}"`);
4951
- const rel = isKernel ? ".agents/injections/oats-defaults/oats.md" : isWorkMode ? `.agents/injections/workmodes/${target}.md` : `.agents/injections/capabilities/${target}.md`;
4952
- const destAbs = join(dir, rel);
4953
- if (existsSync(destAbs)) die(`${shortPath(destAbs)} already exists — edit it directly (it is already your override)`);
4954
- let text = readFileSync(file, "utf8");
4955
- if (!isWorkMode && !isKernel) {
4956
- const caps = readCapabilitiesModel(file);
4957
- const entry = Object.values(caps.layers).find((e) => e && e !== "none" && e.capability === target) || caps.additive[target];
4958
- if (!entry) die(`capability "${target}" has no entry in ${shortPath(file)} — activate it first (oats use ${target})`);
4959
- const m = capabilityManifest(target, dir);
4960
- const owned = entry.from === "owned" || String(entry.from || "").startsWith("path:") || String(m?._origin || "").startsWith("owned:") || String(m?._origin || "").startsWith("path:");
4961
- if (owned) die(`"${target}" is owned/path-sourced — you own its source; edit its injects/ file directly instead of ejecting`);
4962
- entry["injection-override"] = rel;
4963
- text = replaceCapabilitiesBlock(text, caps);
4964
- } else {
4965
- const lines = text.replace(/\n*$/, "\n").split("\n");
4966
- const headRe = isKernel ? /^oats:\s*(#.*)?$/ : /^work-modes:\s*(#.*)?$/;
4967
- let idx = lines.findIndex((l) => headRe.test(l));
4968
- if (idx < 0) { lines.push("", isKernel ? "oats:" : "work-modes:"); idx = lines.length - 1; }
4969
- if (isKernel) {
4970
- lines.splice(idx + 1, 0, ` injection-override: ${rel}`);
4971
- const c = lines.findIndex((l, i2) => i2 > idx + 1 && l.trim() === `# injection-override: ${rel}`);
4972
- if (c >= 0) lines.splice(c, 1);
4973
- } else {
4974
- let mIdx = lines.findIndex((l, i2) => i2 > idx && new RegExp(`^ ${target}:`).test(l));
4975
- if (mIdx < 0) { lines.splice(idx + 1, 0, ` ${target}:`, ` injection-override: ${rel}`); }
4976
- else {
4977
- lines.splice(mIdx + 1, 0, ` injection-override: ${rel}`);
4978
- const c = lines.findIndex((l, i2) => i2 > mIdx + 1 && l.trim() === `# injection-override: ${rel}`);
4979
- if (c >= 0) lines.splice(c, 1);
4980
- }
4981
- }
4982
- text = lines.join("\n").replace(/\n*$/, "\n");
4983
- }
4984
- mkdirSync(dirname(destAbs), { recursive: true });
4985
- writeFileSync(destAbs, readFileSync(src, "utf8"));
4986
- writeFileSync(file, text);
4987
- console.log(`Ejected packaged injection → ${shortPath(destAbs)}`);
4988
- console.log(`Set injection-override in ${shortPath(file)}. Edit the ejected file; it no longer tracks package updates.`);
4989
- }
4990
-
4991
3203
  // ---------- update ----------
4992
3204
  function updateCmd() {
4993
3205
  const checkOnly = args.includes("--check");
@@ -5038,7 +3250,10 @@ function versionCmd() {
5038
3250
  // on it (an older CLI without the surface must fail closed with a
5039
3251
  // reason, not an argument error). `features`: kernel abilities a peer
5040
3252
  // must see before relying on them (retire-home: retire --home).
5041
- 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", "schedule-read-2", "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: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
3253
+ // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3254
+ // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3255
+ // never listed.
3256
+ 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"] }));
5042
3257
  return;
5043
3258
  }
5044
3259
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -5176,7 +3391,7 @@ async function serverRouteCmd() {
5176
3391
  // The operations contract addresses an exact member context on the host,
5177
3392
  // so its explicit --dir travels; every other routed command takes its
5178
3393
  // scope from the registration.
5179
- const explicitScopeOk = ["inspect", "operation", "use", "soul", "launch-config"].includes(cmd);
3394
+ const explicitScopeOk = ["inspect", "operation", "soul", "launch-config"].includes(cmd);
5180
3395
  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");
5181
3396
  if (cmd === "launch-config") {
5182
3397
  const action = args[1];
@@ -5402,6 +3617,8 @@ async function serverRouteCmd() {
5402
3617
  // added lines rather than ~150 lines of pure whitespace churn, and keeps `git
5403
3618
  // blame` pointing at the commit that last changed each command.
5404
3619
  const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3620
+ /** Removed 0.24 verbs → their v2 replacement (workspace model v2, decision 5). Checked before capability dispatch. */
3621
+ 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" };
5405
3622
  try {
5406
3623
  // Inspect explicit selectors with the existing parser before new-work routing,
5407
3624
  // including selectors before the command. Inherited captures are not prepare inputs.
@@ -5431,12 +3648,13 @@ if (cmd === "prepare" || captured?.args[0] === "prepare") {
5431
3648
  }
5432
3649
  if (cmd === "onboard" || captured?.args[0] === "onboard") {
5433
3650
  if (captured) {
5434
- if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit classic bootstrap and cannot use captured selectors");
5435
- die("onboard is explicit classic bootstrap and cannot use captured selectors");
3651
+ if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit workspace bootstrap and cannot use captured selectors");
3652
+ die("onboard is explicit workspace bootstrap and cannot use captured selectors");
5436
3653
  }
5437
3654
  if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
5438
- onboardCmd(); process.exit(0);
3655
+ await onboardCmd();
5439
3656
  }
3657
+ else {
5440
3658
  // Other commands retain their existing explicit/inherited selection rules.
5441
3659
  try { captured ??= capturedSelector(args); }
5442
3660
  catch (error) {
@@ -5455,7 +3673,7 @@ if (captured) {
5455
3673
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
5456
3674
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
5457
3675
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
5458
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
3676
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
5459
3677
  else if (cmd === "server") serverCmd();
5460
3678
  else if (cmd === "inspect") inspectCmd();
5461
3679
  else if (cmd === "operation") operationCmd();
@@ -5465,35 +3683,29 @@ else if (cmd === "doctor") {
5465
3683
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
5466
3684
  args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
5467
3685
  }
5468
- else if (cmd === "use") use();
5469
3686
  else if (cmd === "update") {
3687
+ // `oats update <package>` left with the installed tier (packages are pinned in
3688
+ // the workspace file: `oats package add`); only the kernel self-update remains.
5470
3689
  const t = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
5471
- // A selector without a package must never fall through to the kernel
5472
- // self-update (a different product) with the flag silently ignored.
5473
- 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); }
5474
- t ? updatePackageCmd(t) : updateCmd();
3690
+ 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); }
3691
+ updateCmd();
5475
3692
  }
5476
3693
  else if (cmd === "type") typeCmd();
5477
- else if (cmd === "inject") injectCmd();
5478
- else if (cmd === "install") install();
5479
- else if (cmd === "config") configCmd();
5480
- else if (cmd === "trust") trust();
5481
- else if (cmd === "list") listCmd();
5482
- else if (cmd === "catalog") catalogCmd();
5483
3694
  else if (cmd === "readiness") readinessCmd();
5484
3695
  else if (cmd === "instance") instanceCmd();
5485
- else if (cmd === "remove") removeCmd();
5486
- else if (cmd === "migrate") migrateCmd();
5487
3696
  else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
5488
- else if (cmd === "init") init();
5489
- else if (cmd === "status") status();
3697
+ else if (cmd === "sync") await syncCmd();
3698
+ else if (cmd === "package") await packageCmd();
3699
+ else if (cmd === "workspace") await workspaceCmd();
3700
+ else if (cmd === "capabilities" || cmd === "souls") await itemsCmd(cmd);
3701
+ else if (cmd === "status") await status();
5490
3702
  else if (cmd === "pane") await paneCmd();
5491
3703
  else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
5492
3704
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
5493
3705
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
5494
3706
  else if (cmd === "session") await sessionCmd();
5495
3707
  else if (cmd === "schedule") scheduleCmd();
5496
- 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; } }
3708
+ 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; } }
5497
3709
  else if (cmd === "retire") retireCmd();
5498
3710
  else if (cmd === "create") createCmd();
5499
3711
  else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
@@ -5502,14 +3714,24 @@ else if (cmd === "experimental") await experimentalCmd();
5502
3714
  // word, so without this it reaches the capability dispatch, which resolves the
5503
3715
  // config chain and reads every lock in it — and a scope whose lock the kernel
5504
3716
  // refuses could then not print its own usage, which is exactly when you need it.
5505
- else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
3717
+ // A removed 0.24 verb names its v2 replacement in BOTH modes, before any capability namespace could shadow it.
3718
+ else if (cmd && Object.hasOwn(REMOVED_VERBS, cmd)) {
3719
+ const message = `unknown command "${cmd}" — removed by the workspace model v2; use ${REMOVED_VERBS[cmd]}`;
3720
+ if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: cmd, replacement: REMOVED_VERBS[cmd] });
3721
+ console.error(`oats: ${message}\n`);
3722
+ console.log(usageText());
3723
+ process.exit(1);
3724
+ }
3725
+ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && await capabilityCommand()) { /* dispatched */ }
5506
3726
  // No matching kernel command or capability namespace: in --json mode the help
5507
3727
  // text must NOT contaminate stdout — still one envelope object, nonzero exit.
5508
3728
  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`);
5509
3729
  else {
3730
+ if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
5510
3731
  console.log(usageText());
5511
3732
  process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
5512
3733
  }
3734
+ } // end: every command but onboard
5513
3735
 
5514
3736
  /** The usage lines for one kernel command (its `oats <cmd> ...` lines and
5515
3737
  * their indented continuations), or the whole usage when none match. */
@@ -5559,7 +3781,7 @@ Usage:
5559
3781
  oats session start --server <id> start a stopped remote instance in its existing home
5560
3782
  --instance <name> | --home <abs> over its saved route; the server must advertise
5561
3783
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
5562
- oats inspect|operation|use|soul --server <id> the same commands on a registered server over its
3784
+ oats inspect|operation|soul --server <id> the same commands on a registered server over its
5563
3785
  ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
5564
3786
  is its own context; else the registered workspace);
5565
3787
  soul set --instructions-file streams the bytes; the
@@ -5568,9 +3790,11 @@ Usage:
5568
3790
  --instance <name> | --home <abs> attachments over its saved route (bytes stream on
5569
3791
  --file <path> [--json] ssh stdin; sha256 verified); the server must
5570
3792
  advertise session-upload (oats 0.22.13 or later)
5571
- oats onboard [--dir <deployment>] bootstrap a LOCAL setup expert from official
5572
- [--workspace <git:source[@revision]>] capabilities; classic path, not captured prepare;
5573
- [--force-existing] [--json] prints the next spawn command, never launches
3793
+ oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
3794
+ [--json] and agents/, then runs the oats sync path (lock v3;
3795
+ exit 2 while approvals are pending) and prints the
3796
+ next steps (clone members you work IN, spawn
3797
+ oats-setup-expert); creates no soul, spawns nothing
5574
3798
  oats create <name> [--local] [--no-oats-core] create an agent soul; --local = full
5575
3799
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
5576
3800
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
@@ -5654,28 +3878,21 @@ Usage:
5654
3878
  --soul shows final composed AGENTS.md
5655
3879
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
5656
3880
  optionally run the update; then run oats doctor
5657
- oats install [<source>] [--dir <d>] acquire + exact-lock a package closure
5658
- (git:host/org/repo@ref[#<path>], git URL,
5659
- local path, official catalog id) or a legacy
5660
- marketplace capability; never activates
5661
- #<path> selects the contained package root
5662
- (default oats-package; #. = repository root;
5663
- local paths are always exact directories)
5664
- [--recursive] [--no-requirements] bare \`oats install\` exactly restores this
5665
- [--accept-requirement <cmd> ...] chain's locked packages + capabilities; at a
5666
- [--json] team: scope (or with --recursive) it reconciles
5667
- the whole workspace — descendant scopes restore
5668
- once in path order (pruned discovery), then the
5669
- host-requirement consent gate runs;
5670
- --no-requirements = package-only (CI);
5671
- non-interactive runs never install host tools
5672
- unless each requirement is named explicitly;
5673
- --json = one envelope (failures carry the full
5674
- report under error.details)
5675
- oats list [--dir <d>] [--json] installed packages, exported capabilities,
5676
- scopes, trust state
5677
- oats catalog [--json] the effective official package catalog (read-only:
5678
- identity/discovery, no acquisition or trust)
3881
+ oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
3882
+ oats-local.yaml over its Git remote, confirm every
3883
+ member (reciprocal oats-membership.yaml), resolve
3884
+ packages: to exact commits, ask executable approval
3885
+ once per package version (TTY; otherwise list what
3886
+ needs it and exit 2), write oats-lock.json (v3) and
3887
+ report the diff
3888
+ oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
3889
+ | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3890
+ print the line to add (the file travels through Git)
3891
+ oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
3892
+ cannot-read / backlink-elsewhere), packages, approval
3893
+ oats capabilities [--dir <d>] [--json] every non-private capability of every confirmed
3894
+ oats souls [--dir <d>] [--json] member + the locked packages, with origin
3895
+ (member <key> @ <commit> | package <id> v<ver>) and team
5679
3896
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
5680
3897
  read-only Git observation of the instance's work
5681
3898
  tree: branch, status (renames kept), ahead/behind
@@ -5713,68 +3930,8 @@ Usage:
5713
3930
  <workspace>/.agents/worktrees/<repo>/<branch>)
5714
3931
  unless discarded; --delete-branch deletes the
5715
3932
  worktree's verified branch and implies discard
5716
- oats update <package> [<package>@<ref>] transactional package update: temp fetch,
5717
- [--to <ref>] [--dir <d>] closure validation, diff, lock replace,
5718
- all capability approvals invalidated; a
5719
- spec or --to moves a catalog lock to <ref>
5720
- oats remove <package> [--dir <d>] remove a package (refuses while config or
5721
- dependent packages reference it)
5722
- oats migrate [--dry-run] [--dir <d>] map this scope's v1 capability locks to
5723
- package locks (preserves config activation)
5724
- oats migrate --official [--recursive] guided upgrade of 0.18 bundled official
5725
- [--dry-run] [--dir <d>] [--json] capabilities to official packages: plans every
5726
- visible lock-owning scope first, applies each
5727
- transactionally, keeps custom/owned entries
5728
- untouched, and prints the exact trust/install
5729
- follow-up (held when the catalog cannot map yet)
5730
- oats migrate --from-oas [--recursive] convert a pre-rename OAS deployment in place:
5731
- [--dry-run] [--dir <d>] [--json] renames oas-* files, the oas: config key and
5732
- capability ids, then chains the guided package
5733
- conversion — one transaction per scope, any
5734
- failure restores the original OAS bytes
5735
- oats config diff [--config <template>] three-way report: your config vs the recorded
5736
- [--dir <d>] [--json] adopted base vs the template in the current exact
5737
- lock — reports only, never writes; the adopted
5738
- base supplies the package/template defaults
5739
- oats config sync [--accept <r>=local|package] apply the template's changes to your config,
5740
- [--dir <d>] [--json] region by region, preserving every untouched local
5741
- byte, comment and ordering; local-only edits stay;
5742
- conflicts need an explicit --accept and are never
5743
- chosen for you; advances the recorded base
5744
- oats config sync --reset --yes replace your config with the template verbatim;
5745
- [--config <template>] [--dir <d>] previews every local change it discards, refuses
5746
- [--json] without --yes, and keeps a recoverable .bak
5747
- oats config adopt <package> switch to another installed package's template,
5748
- [--config <template>] [--accept ...] rebasing your one local config; exactly one adopted
5749
- [--dir <d>] [--json] base survives, and a failed switch changes nothing
5750
- oats trust <capability> [--dir <dir>] approve that capability's commands, hooks, and
5751
- launch-environment authority at
5752
- the provider package's exact integrity
5753
- oats trust <package> --all-capabilities explicit bulk approval with a full
5754
- executable-surface summary
5755
- oats use <capability> activate for one config-owned target
5756
- [--global|--type <t>|--soul <s>] (--global is default); --disable excludes
5757
- [--disable] [--settings k=v [k2=v2 ...]] [--dir <d>]
5758
- oats use none --layer <layer> explicitly disable a fundamental layer
5759
3933
  oats type add <name> [--description <d>] declare an agent type (family) in config;
5760
3934
  oats type list souls join via create --type / soul.yaml
5761
- oats inject eject <cap|work-mode|oats> copy a packaged injection to the conventional
5762
- [--dir <d>] .agents/injections/ path and set injection-override
5763
- oats init [--raw] [--dir <dir>] [--json] create an oats-config.yaml here. Fundamental
5764
- [--knowledge <id|none>] layers are filled from what is already at this
5765
- [--messaging <id|none>] scope, else acquired from the official package
5766
- [--tasks <id|none>] that supplies them — capabilities materialize
5767
- [--tmux-mouse|--no-tmux-mouse] flat, executable surfaces stay untrusted, and
5768
- the whole run rolls back on any failure.
5769
- [--package <id|path|git-url>] instead: adopt one config TEMPLATE from a package
5770
- [--config <template>] as your own local config and record the exact
5771
- adopted base (named template, else the marked
5772
- default, else the only one).
5773
- [--template <name|path|git-url>] instead: seed from a template config (named via an
5774
- outer templates: map, a local file, or a git repo's
5775
- default-branch oats-config.yaml).
5776
- Every form refuses to overwrite an existing config;
5777
- --json = exactly one result envelope, noninteractive.
5778
3935
  oats root print this package's install root
5779
3936
  (adapters resolve the kernel from it)
5780
3937
 
@@ -5832,7 +3989,7 @@ The turn record (core — every conversation captured, searchable, replicated):
5832
3989
  oats <namespace> <command> [args…] run an operational command only when its
5833
3990
  capability is active (e.g. oats okf harvest)
5834
3991
 
5835
- Layers: ${LAYERS.join(", ")}. Level detection: ~ → laptop, .git → repo, else workspace.`;
3992
+ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
5836
3993
  }
5837
3994
  } catch (e) {
5838
3995
  if (!TYPED_CLI_FAILURES.has(e?.code)) throw e;