@awebai/oats 0.24.13 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +930 -2820
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +324 -5
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +233 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +90 -16
- package/lib/workspace.mjs +635 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
package/bin/oats.mjs
CHANGED
|
@@ -3,43 +3,46 @@
|
|
|
3
3
|
* oats — the OATS command line.
|
|
4
4
|
*
|
|
5
5
|
* oats doctor [dir] [--json] show the resolved config with origins
|
|
6
|
-
* oats
|
|
7
|
-
*
|
|
8
|
-
* oats
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
|
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,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
|
|
38
|
+
assertNoSymlinkedParents, writeFileAtomic,
|
|
39
|
+
LOCK_FILE, readLock, writeLock, resolvePackages, approve as approvePackage, executablesDigest, readPackageTree,
|
|
40
|
+
classifyPackageValue, manifestExecutables, parsePackageRequest,
|
|
42
41
|
} from "../lib/packages.mjs";
|
|
42
|
+
import { loadLocal, discoverWorkspace, validateWorkspace } from "../lib/workspace.mjs";
|
|
43
|
+
import * as remoteModule from "../lib/remote.mjs";
|
|
44
|
+
import { createInterface } from "node:readline/promises";
|
|
45
|
+
import YAML from "yaml";
|
|
43
46
|
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
44
47
|
import { spawnSync as spawnSyncProc } from "node:child_process";
|
|
45
48
|
import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
|
|
@@ -49,26 +52,22 @@ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_
|
|
|
49
52
|
import { capturedSelector } from "../lib/captured-selector.mjs";
|
|
50
53
|
import { inspectCapturedPiOutcome } from "../lib/captured-pi-host.mjs";
|
|
51
54
|
import { readCapturedResolution } from "../lib/captured-resolutions.mjs";
|
|
52
|
-
import { readLock3 } from "../lib/portable-lock.mjs";
|
|
53
55
|
import { oatsError } from "../lib/errors.mjs";
|
|
54
|
-
import {
|
|
55
|
-
import { canonicalJson, parseStrictJson } from "../lib/portable-values.mjs";
|
|
56
|
+
import { canonicalJson } from "../lib/portable-values.mjs";
|
|
56
57
|
import { readPortablePreparationRequest } from "../lib/portable-onboarding-request.mjs";
|
|
57
58
|
import { portableScope } from "../lib/portable-state.mjs";
|
|
58
59
|
import { CAPTURED_OPERATION_TIMEOUT_MS, runCapturedOperationProcess } from "../lib/captured-operation-process.mjs";
|
|
59
60
|
import { approveCapturedCapability } from "../lib/artifact-approvals.mjs";
|
|
60
|
-
import { loadSetupExpertEdition, SETUP_EXPERT, SETUP_CAPABILITIES } from "../lib/setup-expert-source.mjs";
|
|
61
61
|
import { observeInstanceGit, diffInstanceFile } from "../lib/instance-git.mjs";
|
|
62
62
|
import { planStop, applyStop, planRetire, resolveInstance as resolveInstanceForCli } from "../lib/instance-lifecycle.mjs";
|
|
63
63
|
const await_import_lifecycle = () => ({ resolveInstance: resolveInstanceForCli });
|
|
64
64
|
import { readinessOf, policyOf } from "../lib/readiness.mjs";
|
|
65
65
|
import { readEvents } from "../lib/instance-events.mjs";
|
|
66
|
-
import { parsePortableSource } from "../lib/source-spec.mjs";
|
|
67
66
|
|
|
68
67
|
const args = process.argv.slice(2);
|
|
69
68
|
let cmd = args[0];
|
|
70
69
|
const HELP_WORDS = new Set(["help", "--help", "-h"]);
|
|
71
|
-
const KERNEL_COMMANDS = new Set(["prepare", "capture", "
|
|
70
|
+
const KERNEL_COMMANDS = new Set(["prepare", "capture", "capabilities", "create", "doctor", "inspect", "instance", "operation", "package", "readiness", "soul", "souls", "launch-config", "experimental", "onboard", "pane", "recall", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "sync", "type", "update", "version", "workspace"]);
|
|
72
71
|
const flag = (name) => {
|
|
73
72
|
const i = args.indexOf(`--${name}`);
|
|
74
73
|
return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
|
|
@@ -83,6 +82,7 @@ function valueFlag(name) {
|
|
|
83
82
|
return value;
|
|
84
83
|
}
|
|
85
84
|
const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
|
|
85
|
+
const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
86
86
|
/** Resolve the --dir flag with central validation: a value-taking flag given
|
|
87
87
|
* no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
|
|
88
88
|
* uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
|
|
@@ -506,26 +506,6 @@ function scaffoldConfigName(dir) {
|
|
|
506
506
|
return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
function offerTmuxMouseScrolling() {
|
|
510
|
-
if (args.includes("--no-tmux-mouse")) return;
|
|
511
|
-
const configPath = tmuxConfigPath();
|
|
512
|
-
const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
513
|
-
if (tmuxMouseEnabled(current)) return;
|
|
514
|
-
|
|
515
|
-
let accepted = args.includes("--tmux-mouse");
|
|
516
|
-
if (!accepted) {
|
|
517
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
518
|
-
process.stdout.write("Enable normal mouse/trackpad scrolling in tmux agent windows? [Y/n] ");
|
|
519
|
-
const buffer = Buffer.alloc(256);
|
|
520
|
-
const length = readSync(process.stdin.fd, buffer, 0, buffer.length);
|
|
521
|
-
accepted = !buffer.subarray(0, length).toString("utf8").trim().toLowerCase().startsWith("n");
|
|
522
|
-
}
|
|
523
|
-
if (!accepted) return;
|
|
524
|
-
|
|
525
|
-
const result = enableTmuxMouse(configPath);
|
|
526
|
-
console.log(`Enabled tmux mouse scrolling in ${shortPath(result.configPath)}${result.reloaded ? " (reloaded)" : ""}`);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
509
|
// ---------- doctor ----------
|
|
530
510
|
/** Doctor must diagnose, not crash: a stale activation of a retired
|
|
531
511
|
* capability fails config resolution — surface the cleanup instruction
|
|
@@ -560,31 +540,32 @@ function doctorComposition(ctx, soulName) {
|
|
|
560
540
|
if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
|
|
561
541
|
return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
|
|
562
542
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
*
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
543
|
+
|
|
544
|
+
/** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
|
|
545
|
+
* walking up from ctx (oats-local.yaml) and the lock v3 beside it. Doctor never
|
|
546
|
+
* goes to the network; membership and discovery are `oats sync` / `oats workspace status`. */
|
|
547
|
+
function doctorLockData(ctx) {
|
|
548
|
+
const out = { local: null, localError: null, lockFile: null, packages: [], lockError: null };
|
|
549
|
+
let lockDir = ctx;
|
|
550
|
+
try {
|
|
551
|
+
const found = loadLocal(ctx);
|
|
552
|
+
out.local = { path: found.path, workspace: found.local.workspace };
|
|
553
|
+
lockDir = dirname(found.path);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
if (e?.code === "E_WORKSPACE_SCHEMA") out.localError = { code: e.code, message: e.message };
|
|
556
|
+
else if (e?.code !== "E_LOCAL_MISSING") throw e;
|
|
557
|
+
}
|
|
558
|
+
const file = join(lockDir, LOCK_FILE);
|
|
559
|
+
if (!existsSync(file)) return out;
|
|
560
|
+
out.lockFile = file;
|
|
561
|
+
try {
|
|
562
|
+
const lock = readLock(lockDir);
|
|
563
|
+
out.packages = Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, path: p.path, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
|
|
564
|
+
} catch (e) {
|
|
565
|
+
if (e?.code !== "E_LOCK_SCHEMA") throw e;
|
|
566
|
+
out.lockError = { code: e.code, message: e.message, file: e.details?.file ?? file };
|
|
580
567
|
}
|
|
581
|
-
|
|
582
|
-
const boundary = teamScope || ctx;
|
|
583
|
-
const command = `oats migrate --official --recursive --dir ${shellQuote(boundary)}`;
|
|
584
|
-
const missing = capabilities.filter((c) => !c.available);
|
|
585
|
-
return missing.length
|
|
586
|
-
? { status: "unavailable", capabilities, command: null, reason: `no official package mapping yet for ${missing.map((c) => c.capability).join(", ")} — this release keeps the legacy capabilities working; migration becomes available when the catalog publishes them` }
|
|
587
|
-
: { status: "ready", capabilities, command, reason: null };
|
|
568
|
+
return out;
|
|
588
569
|
}
|
|
589
570
|
|
|
590
571
|
/** The health of ONE materialized capability, against the rows it was projected
|
|
@@ -608,7 +589,7 @@ function hasExecutableSurface(manifest) {
|
|
|
608
589
|
}
|
|
609
590
|
function capabilityHealth(level, cap, capRow, pkgRow) {
|
|
610
591
|
const dir = installedCapabilityDir(level, cap.id);
|
|
611
|
-
if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats
|
|
592
|
+
if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats sync\` to re-materialize it` };
|
|
612
593
|
let integrity;
|
|
613
594
|
try { integrity = capabilityArtifactIntegrity(dir); }
|
|
614
595
|
catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
|
|
@@ -622,113 +603,10 @@ function capabilityHealth(level, cap, capRow, pkgRow) {
|
|
|
622
603
|
catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
|
|
623
604
|
}
|
|
624
605
|
const executable = hasExecutableSurface(cap.manifest);
|
|
625
|
-
if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats
|
|
606
|
+
if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — approve it in \`oats sync\`` };
|
|
626
607
|
return { status: "ok", code: null, dir, integrity, detail: null };
|
|
627
608
|
}
|
|
628
609
|
|
|
629
|
-
function doctorPackagesData(ctx, chain, { teamScope } = {}) {
|
|
630
|
-
// reviewer-455ba15 fix 4: the ENGINE diagnostics the human doctor renders
|
|
631
|
-
// (invalid locks, missing artifacts, integrity/runtime-closure drift,
|
|
632
|
-
// capability-list mismatches, untrusted surfaces, legacy-lock states)
|
|
633
|
-
// are computed HERE so doctor --json exposes them structurally — machine
|
|
634
|
-
// consumers see every state the human report calls broken. Fail-closed
|
|
635
|
-
// reads are diagnosed, never consumed as data and never swallowed.
|
|
636
|
-
let pkgLocks = { packages: {}, legacy: [] };
|
|
637
|
-
let installedPkgs = [];
|
|
638
|
-
let lockBroken = null;
|
|
639
|
-
try { pkgLocks = readPackageLocks(ctx); installedPkgs = listInstalledPackages(ctx); }
|
|
640
|
-
catch (e) {
|
|
641
|
-
const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
|
|
642
|
-
lockBroken = { code: e.code || "invalid-lock", message: String(e.message || e), file: prov?.file || null, provenance: e.provenance || null };
|
|
643
|
-
}
|
|
644
|
-
const packages = [];
|
|
645
|
-
for (const p of installedPkgs) {
|
|
646
|
-
// SCOPE-EXACT, like `oats list` and `oats trust` above: `p` was derived from
|
|
647
|
-
// the lock AT `p.level`, and its artifacts live under that scope, so only
|
|
648
|
-
// that scope's rows can judge them. The MERGED maps resolve each identity
|
|
649
|
-
// closest-scope-first — right for "which capability is active here", wrong
|
|
650
|
-
// here — so a chain holding one package id at two scopes (a direct
|
|
651
|
-
// acquisition outside, the same id pulled in by a dependency closure
|
|
652
|
-
// inside, each with its own source spelling) would compare an outer
|
|
653
|
-
// artifact's provenance against the inner row it was never projected from
|
|
654
|
-
// and report a self-consistent pair as invalid-lock.
|
|
655
|
-
const rows = levelRows(pkgLocks, p.level);
|
|
656
|
-
const lock = Object.hasOwn(rows.packages, p.package) ? { ...rows.packages[p.package], _file: join(p.level, OATS_LOCK_FILE), _level: p.level } : undefined;
|
|
657
|
-
const problems = [];
|
|
658
|
-
if (!lock) problems.push({ code: "invalid-lock", detail: "installed but not locked — reacquire it" });
|
|
659
|
-
else {
|
|
660
|
-
// There is no persistent package root to hash: the package row exact-locks
|
|
661
|
-
// a remote payload, and the only bytes on disk are the flat capability
|
|
662
|
-
// artifacts. So every health check is per capability, against the artifact
|
|
663
|
-
// integrity the engine recorded for it.
|
|
664
|
-
for (const c of p.capabilities) {
|
|
665
|
-
const h = capabilityHealth(p.level, c, Object.hasOwn(rows.capabilities, c.id) ? rows.capabilities[c.id] : undefined, lock);
|
|
666
|
-
if (h.status !== "ok") problems.push({ code: h.code, detail: h.detail });
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
packages.push({
|
|
670
|
-
id: p.package, version: p.version || null, level: p.level, source: lock?.source || null,
|
|
671
|
-
path: lock?.path || null, commit: lock?.commit || null, capabilities: p.capabilities.map((c) => c.id),
|
|
672
|
-
dependencies: lock?.dependencies || [],
|
|
673
|
-
status: problems.length ? "broken" : "ok", problems,
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
for (const [id, lock] of Object.entries(pkgLocks.packages)) {
|
|
677
|
-
if (!installedPkgs.some((p) => p.package === id)) {
|
|
678
|
-
// Capability rows carry the provider back-reference — the package row has
|
|
679
|
-
// no capability list to read any more.
|
|
680
|
-
const provided = Object.entries(pkgLocks.capabilities).filter(([, c]) => c.package === id).map(([capId]) => capId);
|
|
681
|
-
packages.push({ id, version: lock.version || null, level: lock._level, source: lock.source || null, path: lock.path || null, commit: lock.commit || null, capabilities: provided, dependencies: lock.dependencies || [], status: "broken", problems: [{ code: "missing-locked-package", detail: `locked in ${lock._file} but not installed — run oats install` }] });
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
// Supported v1 scopes — empty or not — are pending an explicit LOCK-FORMAT
|
|
685
|
-
// migration (maintainer ruling). There is no second view beside this one:
|
|
686
|
-
// migration never produces residue, and the superseded transitional v2 shape
|
|
687
|
-
// is rejected wholesale by the strict reader, so it reaches doctor as the
|
|
688
|
-
// single `lockError` diagnosis above rather than as partially parsed entries.
|
|
689
|
-
const legacyLockFiles = pkgLocks.legacy
|
|
690
|
-
.map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion ?? 1, empty: !Object.keys(l.capabilities || {}).length, status: "pending-format-migration", action: `oats migrate --dir ${l.level}` }));
|
|
691
|
-
// Adoption provenance now comes from the visible, commit-safe adopted base —
|
|
692
|
-
// not from a provenance comment the local config could lose to an edit.
|
|
693
|
-
const adoptedTemplates = [];
|
|
694
|
-
for (const cfg of chain) {
|
|
695
|
-
const level = dirname(cfg._file);
|
|
696
|
-
let adopted;
|
|
697
|
-
try { adopted = readAdoptedTemplate(level); }
|
|
698
|
-
catch (e) { adoptedTemplates.push({ level, file: cfg._file, status: "broken", code: e.code || "E_ADOPTION_INVALID", detail: e.message }); continue; }
|
|
699
|
-
if (!adopted) continue;
|
|
700
|
-
let localChanges = null;
|
|
701
|
-
try { localChanges = readFileSync(cfg._file, "utf8") !== adopted.baseText; } catch { /* unreadable config is reported elsewhere */ }
|
|
702
|
-
adoptedTemplates.push({
|
|
703
|
-
level, file: cfg._file, package: adopted.package, template: adopted.template,
|
|
704
|
-
base: adopted.baseFile, source: adopted.metadata?.source || null,
|
|
705
|
-
version: adopted.metadata?.version || null, commit: adopted.metadata?.commit || null,
|
|
706
|
-
hash: adopted.metadata?.hash || null, localChanges, status: "ok",
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
// NOTE: doctor deliberately does NOT enumerate templates a package exports but
|
|
710
|
-
// nobody adopted. In the materialized model there is no package root on disk,
|
|
711
|
-
// so that list only exists behind a network fetch of the locked source — and
|
|
712
|
-
// a diagnostic command must never go to the network to render a hint.
|
|
713
|
-
const missingHostRequirements = aggregateMissingRequirements([ctx]).map((req) => ({
|
|
714
|
-
command: req.command, why: req.why || null, docs: req.docs || null,
|
|
715
|
-
requestedBy: req.requestedBy,
|
|
716
|
-
plan: req.plan && !req.plan.unavailable
|
|
717
|
-
? { manager: req.plan.manager, argv: req.plan.argv, steps: req.plan.steps || [req.plan.argv], source: req.plan.source, version: req.plan.version || null, scope: req.plan.scope }
|
|
718
|
-
: null,
|
|
719
|
-
invalid: req.invalid || null,
|
|
720
|
-
conflict: req.conflict || null,
|
|
721
|
-
unavailable: req.plan?.unavailable || null,
|
|
722
|
-
// Context-complete + shell-safe: the copyable command pins the resolved
|
|
723
|
-
// scope with --dir so it cannot target another deployment from a
|
|
724
|
-
// different cwd. Command and ctx are validated/quoted for safe copying.
|
|
725
|
-
consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
|
|
726
|
-
? `oats install --accept-requirement ${req.command} --dir ${shellQuote(ctx)}`
|
|
727
|
-
: null,
|
|
728
|
-
}));
|
|
729
|
-
return { lockError: lockBroken, packages, legacyLockFiles, adoptedTemplates, missingHostRequirements, officialMigration: officialMigrationState(pkgLocks.legacy, { teamScope, ctx }) };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
610
|
// ---------- inspect: one authoritative answer for GUIs ----------
|
|
733
611
|
/** Souls, capabilities (installed state and health, separately from
|
|
734
612
|
* activation), effective layer bindings and declared operations for a
|
|
@@ -937,7 +815,7 @@ function computeInspect({ onFail } = {}) {
|
|
|
937
815
|
// Capabilities: installed state and health from the package engine (exactly
|
|
938
816
|
// what `oats list` reports), owned/path manifests beside them, and the
|
|
939
817
|
// ACTIVATION for the selected soul (or global) from the resolver.
|
|
940
|
-
const mans = capabilityManifests(ctx);
|
|
818
|
+
const mans = capabilityManifests(manifestSource(meta, home, ctx));
|
|
941
819
|
let lockError = null;
|
|
942
820
|
const byId = new Map();
|
|
943
821
|
try {
|
|
@@ -998,7 +876,7 @@ function computeInspect({ onFail } = {}) {
|
|
|
998
876
|
const operations = manifestOperations(m).map((op) => {
|
|
999
877
|
let reason = null;
|
|
1000
878
|
if (!active) reason = disabledLayer ? `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` : `${entry.id} is not activated for ${meta ? `home ${basename(home)}` : soulName ? `soul ${soulName}` : "this scope"}`;
|
|
1001
|
-
else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (oats
|
|
879
|
+
else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (approve it in oats sync)`;
|
|
1002
880
|
else if (entry.health.status !== "ok") reason = entry.health.detail || entry.health.status;
|
|
1003
881
|
else if (missingRequires.length) reason = `${entry.id} requires ${missingRequires.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
|
|
1004
882
|
else if (op.context === "home" && !home) reason = "needs a running home (--home)";
|
|
@@ -1201,7 +1079,7 @@ function operationCmd() {
|
|
|
1201
1079
|
}
|
|
1202
1080
|
// Provider resolution: the snapshot's active capabilities for a home, the
|
|
1203
1081
|
// config for a soul/scope.
|
|
1204
|
-
const mans = capabilityManifests(ctx);
|
|
1082
|
+
const mans = capabilityManifests(manifestSource(meta, home, ctx));
|
|
1205
1083
|
let provider, settings, team, disabled = null;
|
|
1206
1084
|
if (meta) {
|
|
1207
1085
|
const ids = (meta.capabilities || []).map((c) => c.id);
|
|
@@ -1222,7 +1100,7 @@ function operationCmd() {
|
|
|
1222
1100
|
const op = manifestOperations(provider).find((o) => o.name === opName);
|
|
1223
1101
|
if (!op) bail("E_OPERATION_UNKNOWN", `${provider.capability} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
|
|
1224
1102
|
const trust = capabilityTrust(provider, ctx);
|
|
1225
|
-
if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (oats
|
|
1103
|
+
if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (approve it in oats sync)`);
|
|
1226
1104
|
const missingReq = capabilityMissingRequires(provider.capability, ctx);
|
|
1227
1105
|
if (missingReq.length) bail("E_CAPABILITY_REQUIRES", `${provider.capability} requires ${missingReq.map((m) => `"${m.command}" on PATH${m.why ? ` (${m.why})` : ""}${m.install ? ` [install: ${m.install}]` : ""}`).join(", ")}; ${address} was not run`);
|
|
1228
1106
|
if (op.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
|
|
@@ -1347,11 +1225,14 @@ async function soulCmd() {
|
|
|
1347
1225
|
function doctorJson(dir) {
|
|
1348
1226
|
const ctx = resolve(dir || process.cwd());
|
|
1349
1227
|
const soulName = flag("soul");
|
|
1228
|
+
const ws = doctorLockData(ctx);
|
|
1229
|
+
// A v2 deployment (oats-local.yaml found walking up) has NO config chain, layers,
|
|
1230
|
+
// acquired packages or installed tier: those keys are omitted, not emitted empty.
|
|
1231
|
+
if (ws.local) { console.log(JSON.stringify(doctorWorkspaceJson(ctx, soulName, ws), null, 2)); return; }
|
|
1350
1232
|
const r = resolveForDoctor(ctx, soulName, { json: true });
|
|
1351
1233
|
const mans = capabilityManifests(ctx);
|
|
1352
1234
|
const composition = doctorComposition(ctx, soulName);
|
|
1353
1235
|
const chain = configChain(ctx);
|
|
1354
|
-
const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
|
|
1355
1236
|
const oasScopes = detectOasScopes(ctx);
|
|
1356
1237
|
console.log(JSON.stringify({
|
|
1357
1238
|
schemaVersion: 1,
|
|
@@ -1377,34 +1258,77 @@ retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); }
|
|
|
1377
1258
|
retiredArtifacts: Object.entries(mans)
|
|
1378
1259
|
.filter(([id]) => retiredCapabilityReason(id))
|
|
1379
1260
|
.map(([id, m]) => ({ id, dir: m._dir, origin: m._origin, reason: retiredCapabilityReason(id) })),
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
missingHostRequirements: pkg.missingHostRequirements,
|
|
1261
|
+
// Workspace model v2 (offline view): the lock (v3) and the deployment
|
|
1262
|
+
// declaration. Human and JSON doctor derive from ONE computation; a
|
|
1263
|
+
// fail-closed lock read is diagnosed via lockError, never consumed as data.
|
|
1264
|
+
workspace: ws.local ? { file: ws.local.path, ref: ws.local.workspace } : null,
|
|
1265
|
+
workspaceError: ws.localError,
|
|
1266
|
+
lockFile: ws.lockFile,
|
|
1267
|
+
packages: ws.packages,
|
|
1268
|
+
lockError: ws.lockError,
|
|
1389
1269
|
composedInstructions: composition?.text,
|
|
1390
1270
|
instructionBlocks: composition?.blocks,
|
|
1391
1271
|
}, null, 2));
|
|
1392
1272
|
}
|
|
1393
1273
|
|
|
1274
|
+
/** The v2 doctor payload: the deployment declaration + lock (offline) and, with
|
|
1275
|
+
* --soul, the composed instructions. No v1 keys (chain/layers/acquired/injects…). */
|
|
1276
|
+
function doctorWorkspaceJson(ctx, soulName, ws) {
|
|
1277
|
+
const composition = doctorComposition(ctx, soulName);
|
|
1278
|
+
return {
|
|
1279
|
+
schemaVersion: 1, workspaceApi: 2, context: ctx,
|
|
1280
|
+
workspace: { file: ws.local.path, ref: ws.local.workspace },
|
|
1281
|
+
workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
|
|
1282
|
+
information: operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : [],
|
|
1283
|
+
composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
/** Kernel/bridge version skew (published in lockstep from one tag). */
|
|
1287
|
+
function doctorVersionSkew() {
|
|
1288
|
+
const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
|
|
1289
|
+
if (!existsSync(piPkgFile)) return;
|
|
1290
|
+
const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
|
|
1291
|
+
if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
|
|
1292
|
+
}
|
|
1293
|
+
/** The "Workspace (v2, offline view)" + "Locked packages" sections, shared by both doctor shapes. */
|
|
1294
|
+
function printDoctorWorkspace(ws) {
|
|
1295
|
+
console.log("\nWorkspace (v2, offline view):");
|
|
1296
|
+
if (ws.local) console.log(` oats-local.yaml ${shortPath(ws.local.path)} → workspace ${ws.local.workspace}`);
|
|
1297
|
+
else if (ws.localError) console.log(` ERROR: ${ws.localError.message} [${ws.localError.code}]`);
|
|
1298
|
+
else console.log(" (no oats-local.yaml found walking up — this scope realizes no v2 workspace; run `oats sync` from one that does)");
|
|
1299
|
+
console.log("\nLocked packages (oats-lock.json v3):");
|
|
1300
|
+
if (ws.lockError) {
|
|
1301
|
+
console.log(` ERROR: ${ws.lockError.message} [${ws.lockError.code}]`);
|
|
1302
|
+
if (ws.lockError.file) console.log(` the lock is never auto-repaired; delete ${shortPath(ws.lockError.file)} and run \`oats sync\``);
|
|
1303
|
+
} else if (!ws.packages.length) console.log(ws.lockFile ? " (none)" : " (no lock yet — run `oats sync`)");
|
|
1304
|
+
for (const p of ws.packages) {
|
|
1305
|
+
console.log(` ${p.id} ${p.version} ${p.source} @ ${p.commit.slice(0, 12)} ${p.approved ? `approved ${p.approved.at}` : "APPROVAL NEEDED (oats sync)"}`);
|
|
1306
|
+
if (p.capabilities.length) console.log(` capabilities: ${p.capabilities.join(", ")}`);
|
|
1307
|
+
}
|
|
1308
|
+
console.log(" membership, discovery and drift need the remotes: `oats workspace status`, `oats sync`.");
|
|
1309
|
+
}
|
|
1394
1310
|
function doctor(dir) {
|
|
1395
1311
|
const ctx = resolve(dir || process.cwd());
|
|
1396
1312
|
const soulName = flag("soul");
|
|
1313
|
+
const ws = doctorLockData(ctx);
|
|
1314
|
+
console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
|
|
1315
|
+
doctorVersionSkew();
|
|
1316
|
+
if (ws.local) {
|
|
1317
|
+
// A v2 deployment: nothing is installed and there is no config chain — the v1
|
|
1318
|
+
// sections (Config chain / Layers / Kernel injection / Acquired packages / lock
|
|
1319
|
+
// warnings) would describe a tier this deployment does not have.
|
|
1320
|
+
const composition = doctorComposition(ctx, soulName);
|
|
1321
|
+
printDoctorWorkspace(ws);
|
|
1322
|
+
if (soulName) {
|
|
1323
|
+
const information = operationalKnowledgeNote(composition, soulName);
|
|
1324
|
+
if (information) console.log(`\nINFO: ${information}`);
|
|
1325
|
+
console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
|
|
1326
|
+
} else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1397
1329
|
const chain = configChain(ctx);
|
|
1398
1330
|
const r = resolveForDoctor(ctx, soulName);
|
|
1399
1331
|
const composition = doctorComposition(ctx, soulName);
|
|
1400
|
-
console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
|
|
1401
|
-
|
|
1402
|
-
// Kernel/bridge version skew (published in lockstep from one tag).
|
|
1403
|
-
const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
|
|
1404
|
-
if (existsSync(piPkgFile)) {
|
|
1405
|
-
const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
|
|
1406
|
-
if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
|
|
1407
|
-
}
|
|
1408
1332
|
|
|
1409
1333
|
console.log("Config chain (closest first):");
|
|
1410
1334
|
if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
|
|
@@ -1485,7 +1409,7 @@ function doctor(dir) {
|
|
|
1485
1409
|
for (const [id, lock] of Object.entries(locks)) {
|
|
1486
1410
|
const retiredReason = retiredCapabilityReason(id);
|
|
1487
1411
|
if (retiredReason) { console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but ${retiredReason}`); continue; }
|
|
1488
|
-
if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats
|
|
1412
|
+
if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats sync\``);
|
|
1489
1413
|
}
|
|
1490
1414
|
for (const [id, m] of Object.entries(mans)) {
|
|
1491
1415
|
if (!String(m._origin).startsWith("installed:")) continue;
|
|
@@ -1500,54 +1424,10 @@ function doctor(dir) {
|
|
|
1500
1424
|
}
|
|
1501
1425
|
if (existsSync(LEGACY_HOME_CAPABILITIES_DIR)) console.log(` WARNING: legacy ~/.oats/capabilities exists and is no longer discovered — reinstall its packages at a config scope and remove it`);
|
|
1502
1426
|
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
|
|
1507
|
-
console.log("\nInstalled packages:");
|
|
1508
|
-
const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
|
|
1509
|
-
if (pkg.lockError) {
|
|
1510
|
-
console.log(` ERROR: ${pkg.lockError.message} [${pkg.lockError.code}]`);
|
|
1511
|
-
if (pkg.lockError.file) console.log(` fix or remove the offending entry in ${shortPath(pkg.lockError.file)} — the lock is never auto-repaired; package operations fail closed until it is valid`);
|
|
1512
|
-
}
|
|
1513
|
-
if (!pkg.lockError && !pkg.packages.length && !pkg.legacyLockFiles.length) console.log(" (none)");
|
|
1514
|
-
for (const p of pkg.packages) {
|
|
1515
|
-
console.log(` ${p.id}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]`);
|
|
1516
|
-
for (const prob of p.problems) {
|
|
1517
|
-
if (prob.code === "untrusted-surface") console.log(` ${prob.detail}`);
|
|
1518
|
-
else console.log(` ERROR: ${prob.detail} [${prob.code}]`);
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
for (const l of pkg.legacyLockFiles) {
|
|
1522
|
-
if (l.empty) console.log(` WARNING: ${shortPath(l.file)} is an empty lockfileVersion ${l.lockfileVersion} file — pending lock-format migration: run \`oats migrate --dir ${shortPath(l.level)}\` (converts to canonical v2)`);
|
|
1523
|
-
else console.log(` WARNING: ${shortPath(l.file)} is lockfileVersion ${l.lockfileVersion} — \`oats migrate\` maps its capability locks to packages`);
|
|
1524
|
-
}
|
|
1525
|
-
if (pkg.officialMigration) {
|
|
1526
|
-
const om = pkg.officialMigration;
|
|
1527
|
-
console.log(`\nOfficial capability migration (0.18 bundled capabilities → official packages):`);
|
|
1528
|
-
for (const c of om.capabilities) {
|
|
1529
|
-
console.log(` ${c.capability} → package ${c.package}${c.via === "alias" ? " (catalog alias)" : ""} ${c.available ? "[mapped]" : "[no catalog mapping yet]"} [${shortPath(c.level)}]`);
|
|
1530
|
-
}
|
|
1531
|
-
if (om.status === "ready") console.log(` READY: migrate with \`${om.command}\` (plan it first with --dry-run; approvals are re-earned afterwards)`);
|
|
1532
|
-
else console.log(` NOT YET AVAILABLE: ${om.reason}`);
|
|
1533
|
-
}
|
|
1534
|
-
for (const a of pkg.adoptedTemplates) {
|
|
1535
|
-
if (a.status === "broken") {
|
|
1536
|
-
console.log(`\nAdopted config template: BROKEN at ${shortPath(a.level)} — ${a.detail}`);
|
|
1537
|
-
continue;
|
|
1538
|
-
}
|
|
1539
|
-
const drift = a.localChanges === null ? "" : a.localChanges ? " — local edits present (`oats config diff`)" : " — no local edits yet";
|
|
1540
|
-
console.log(`\nAdopted config template: ${shortPath(a.file)} adopted ${a.package}:${a.template}${a.version ? `@${a.version}` : ""}${drift}`);
|
|
1541
|
-
console.log(` recorded base ${shortPath(a.base)} (commit it — \`oats config sync\` compares against it; package updates never rewrite your config)`);
|
|
1542
|
-
}
|
|
1543
|
-
if (pkg.missingHostRequirements.length) {
|
|
1544
|
-
console.log("\nMissing host commands (active capabilities):");
|
|
1545
|
-
for (const req of pkg.missingHostRequirements) {
|
|
1546
|
-
console.log(` ${req.command} — ${req.why || "required"} (requested by: ${req.requestedBy.map((r) => r.capability).join(", ")})`);
|
|
1547
|
-
if (req.plan) console.log(` install with consent: ${req.consentCommand} (runs: ${req.plan.argv.join(" ")})`);
|
|
1548
|
-
else if (req.docs) console.log(` install docs: ${req.docs}`);
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1427
|
+
// Workspace model v2: nothing is installed. Doctor reports the lock (v3) and
|
|
1428
|
+
// the deployment declaration OFFLINE — membership, discovery and package
|
|
1429
|
+
// resolution go to the remotes and belong to `oats sync` / `oats workspace status`.
|
|
1430
|
+
printDoctorWorkspace(ws);
|
|
1551
1431
|
|
|
1552
1432
|
if (soulName) {
|
|
1553
1433
|
const information = operationalKnowledgeNote(composition, soulName);
|
|
@@ -1557,86 +1437,6 @@ function doctor(dir) {
|
|
|
1557
1437
|
}
|
|
1558
1438
|
|
|
1559
1439
|
// ---------- config editing (structural: parse → mutate → re-serialize the capabilities block) ----------
|
|
1560
|
-
function originToFrom(origin) {
|
|
1561
|
-
const o = String(origin || "");
|
|
1562
|
-
if (o.startsWith("installed:")) return "installed";
|
|
1563
|
-
if (o.startsWith("owned:")) return "owned";
|
|
1564
|
-
if (o.startsWith("path:")) return undefined; // path declarations stay hand-authored
|
|
1565
|
-
return undefined;
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
function serializeBinding(value, indent) {
|
|
1569
|
-
if (value === true || value === false) return ` ${value}`;
|
|
1570
|
-
const lines = [""];
|
|
1571
|
-
if (value.enabled !== undefined) lines.push(`${indent}enabled: ${value.enabled}`);
|
|
1572
|
-
if (value.settings && Object.keys(value.settings).length) {
|
|
1573
|
-
lines.push(`${indent}settings:`);
|
|
1574
|
-
for (const [k, v] of Object.entries(value.settings)) lines.push(`${indent} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
|
|
1575
|
-
}
|
|
1576
|
-
return lines.join("\n");
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/** Serialize one capability entry map at the given base indent, with the conventional injection comment. */
|
|
1580
|
-
function serializeCapabilityEntry(id, entry, baseIndent) {
|
|
1581
|
-
const i = baseIndent;
|
|
1582
|
-
const lines = [];
|
|
1583
|
-
if (entry.capability) lines.push(`${i}capability: ${entry.capability}`);
|
|
1584
|
-
if (entry.from) lines.push(`${i}from: ${entry.from}`);
|
|
1585
|
-
if (entry.global !== undefined) lines.push(`${i}global:${serializeBinding(entry.global, i + " ")}`);
|
|
1586
|
-
const types = entry["agent-types"];
|
|
1587
|
-
if (types && Object.keys(types).length) {
|
|
1588
|
-
lines.push(`${i}agent-types:`);
|
|
1589
|
-
for (const [t, v] of Object.entries(types)) lines.push(`${i} ${t}:${serializeBinding(v, i + " ")}`);
|
|
1590
|
-
}
|
|
1591
|
-
if (entry.souls && Object.keys(entry.souls).length) {
|
|
1592
|
-
lines.push(`${i}souls:`);
|
|
1593
|
-
for (const [s, v] of Object.entries(entry.souls)) lines.push(`${i} ${s}:${serializeBinding(v, i + " ")}`);
|
|
1594
|
-
}
|
|
1595
|
-
if (entry.settings && Object.keys(entry.settings).length) {
|
|
1596
|
-
lines.push(`${i}settings:`);
|
|
1597
|
-
for (const [k, v] of Object.entries(entry.settings)) lines.push(`${i} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
|
|
1598
|
-
}
|
|
1599
|
-
if (entry["injection-override"] !== undefined) lines.push(`${i}injection-override: ${entry["injection-override"]}`);
|
|
1600
|
-
else if (entry.from === "owned" || String(entry.from || "").startsWith("path:"))
|
|
1601
|
-
lines.push(`${i}# injection edited at source: .agents/capabilities/owned/${id}/injects/`);
|
|
1602
|
-
else lines.push(`${i}# injection-override: .agents/injections/capabilities/${id}.md`);
|
|
1603
|
-
return lines;
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
/** Re-serialize the whole `capabilities:` block from its parsed model. */
|
|
1607
|
-
function serializeCapabilities(caps) {
|
|
1608
|
-
const lines = ["capabilities:", " # Fundamental layers — exclusive slots; a capability entry or an explicit none.", " layers:"];
|
|
1609
|
-
for (const layer of LAYERS) {
|
|
1610
|
-
const entry = caps.layers?.[layer];
|
|
1611
|
-
if (entry === undefined) continue;
|
|
1612
|
-
if (entry === "none") { lines.push(` ${layer}: none`); continue; }
|
|
1613
|
-
lines.push(` ${layer}:`);
|
|
1614
|
-
lines.push(...serializeCapabilityEntry(entry.capability, entry, " "));
|
|
1615
|
-
}
|
|
1616
|
-
const additive = Object.entries(caps.additive || {});
|
|
1617
|
-
if (additive.length) {
|
|
1618
|
-
lines.push(" additive:");
|
|
1619
|
-
for (const [id, entry] of additive) {
|
|
1620
|
-
lines.push(` ${id}:`);
|
|
1621
|
-
lines.push(...serializeCapabilityEntry(id, entry, " "));
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
return lines.join("\n") + "\n";
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
/** Replace (or append) the top-level capabilities: block in config text. */
|
|
1628
|
-
function replaceCapabilitiesBlock(text, caps) {
|
|
1629
|
-
const serialized = serializeCapabilities(caps);
|
|
1630
|
-
const lines = text.replace(/\n*$/, "\n").split("\n");
|
|
1631
|
-
const start = lines.findIndex((l) => /^capabilities:\s*(#.*)?$/.test(l));
|
|
1632
|
-
if (start < 0) return text.replace(/\n*$/, "\n\n") + serialized;
|
|
1633
|
-
let end = lines.length;
|
|
1634
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
1635
|
-
if (/^[^\s#]/.test(lines[i])) { end = i; break; }
|
|
1636
|
-
if (/^#/.test(lines[i]) && i + 1 < lines.length && /^[^\s]/.test(lines[i + 1] || "")) { end = i; break; }
|
|
1637
|
-
}
|
|
1638
|
-
return [...lines.slice(0, start), ...serialized.replace(/\n$/, "").split("\n"), "", ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
|
|
1639
|
-
}
|
|
1640
1440
|
/** Replace (or append, or drop with "") the top-level launch-configs block:
|
|
1641
1441
|
* the span from its key line (bare, quoted, or the inline `launch-configs: {...}`
|
|
1642
1442
|
* form) to the next top-level line is replaced; every byte before and after
|
|
@@ -1887,2248 +1687,568 @@ async function launchConfigCmd() {
|
|
|
1887
1687
|
}
|
|
1888
1688
|
|
|
1889
1689
|
|
|
1890
|
-
/**
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (layer && !LAYERS.includes(layer)) cmdFail("E_BAD_ARGS", `--layer must be one of: ${LAYERS.join(", ")}`);
|
|
1907
|
-
const inherit = args.includes("--inherit");
|
|
1908
|
-
if (inherit && args.includes("--disable")) cmdFail("E_BAD_ARGS", "choose --inherit (remove this level's binding) or --disable (explicit exclusion), not both");
|
|
1909
|
-
let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
|
|
1910
|
-
const caps = readCapabilitiesModel(file);
|
|
1911
|
-
// The receipt names what this level said before and after, and what is
|
|
1912
|
-
// effective afterwards, so a GUI never has to re-read the file to know.
|
|
1913
|
-
const effectiveAfter = (soulName, layerName, capId) => {
|
|
1690
|
+
/** `oats instance <git|diff> <instance>` — K1: read-only Git observation of one
|
|
1691
|
+
* instance's work tree. The instance is addressed qualified: an explicit
|
|
1692
|
+
* --home, or a name under the --dir scope (team roots included) that resolves
|
|
1693
|
+
* to exactly one home; several homes refuse with every candidate named. */
|
|
1694
|
+
function instanceCmd() {
|
|
1695
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
1696
|
+
const sub = args[1], name = args[2];
|
|
1697
|
+
const usage = "usage: oats instance events <instance> [--limit <n>] [--since <iso>] [--home <abs>] [--dir <d>] [--json] | oats instance git <instance> [--home <abs>] [--dir <d>] [--json] | oats instance diff <instance> --file <id> --revision <rev> [--index-revision <rev>] [--home <abs>] [--dir <d>] [--json] | oats instance stop <instance> (--plan | --apply --plan-revision <rev> --idempotency-key <key>) [--no-recursive] [--grace-ms <n>] [--home <abs>] [--dir <d>] [--json]";
|
|
1698
|
+
if (!["git", "diff", "stop", "events"].includes(sub) || !name || name.startsWith("--")) return bail("E_BAD_ARGS", usage);
|
|
1699
|
+
dropAmbientRoot();
|
|
1700
|
+
if (sub === "events") {
|
|
1701
|
+
// K7: typed producer events, bounded window; nothing inferred.
|
|
1702
|
+
const homeOpt = flag("home"); if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
|
|
1703
|
+
let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
|
|
1704
|
+
const limit = flag("limit"); const since = flag("since");
|
|
1705
|
+
if (limit === true || since === true) return bail("E_BAD_ARGS", usage);
|
|
1914
1706
|
try {
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
if (!layer) cmdFail("E_BAD_ARGS", "oats use none requires --layer <name>");
|
|
1924
|
-
// A layer `none` is a LEVEL statement; there is no per-soul or per-type
|
|
1925
|
-
// none, so a target here must be refused, never silently widened.
|
|
1926
|
-
if (flag("soul") !== undefined || flag("type") !== undefined) cmdFail("E_BAD_ARGS", `oats use none --layer ${layer} disables the layer for this whole level; it takes no --soul or --type (exclude one capability for a soul with oats use <capability> --soul <name> --disable)`);
|
|
1927
|
-
const before = caps.layers[layer] === "none" ? "none" : caps.layers[layer] ? caps.layers[layer].capability : null;
|
|
1928
|
-
if (inherit) {
|
|
1929
|
-
if (caps.layers[layer] !== "none") cmdFail("E_NOT_BOUND", `layer ${layer} is not explicitly none at ${level} level (${shortPath(file)}); nothing to inherit from`);
|
|
1930
|
-
delete caps.layers[layer];
|
|
1931
|
-
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
1932
|
-
answer({ capability: null, action: "inherit", target: "layer", layer, level, file, before: { layer: before }, after: { layer: null, effective: effectiveAfter(undefined, layer) } }, `Layer ${layer} at ${level} level now inherits (${shortPath(file)})`);
|
|
1707
|
+
const { resolveInstance } = await_import_lifecycle();
|
|
1708
|
+
// K7b: --home is an ADDRESS claim, checked like K1 — it must be a home of
|
|
1709
|
+
// exactly this name under the scope (E_HOME_MISMATCH otherwise).
|
|
1710
|
+
const home = resolveInstance(dirFlag(), root, name, homeOpt ? { home: homeOpt } : {}).home;
|
|
1711
|
+
const ev = readEvents(home, { ...(limit !== undefined ? { limit: Math.max(1, Math.min(2000, Number(limit) || 200)) } : {}), ...(since ? { since } : {}) });
|
|
1712
|
+
if (JSON_MODE) { jsonOk(ev); return; }
|
|
1713
|
+
console.log(`${ev.instance}: ${ev.returned} of ${ev.count} event(s)${ev.truncated ? " (window truncated)" : ""}${ev.waitingOnYou ? ` — waiting on you since ${ev.waitingOnYou.since} (${ev.waitingOnYou.producer})` : ""}`);
|
|
1714
|
+
for (const e of ev.events) console.log(` ${e.at ?? "?"} ${e.kind.padEnd(20)} ${e.producer}${e.data ? ` ${JSON.stringify(e.data).slice(0, 120)}` : ""}`);
|
|
1933
1715
|
return;
|
|
1934
|
-
}
|
|
1935
|
-
caps.layers[layer] = "none";
|
|
1936
|
-
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
1937
|
-
answer({ capability: null, action: "layer-none", target: "layer", layer, level, file, before: { layer: before }, after: { layer: "none", effective: effectiveAfter(undefined, layer) } }, `Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
|
|
1938
|
-
return;
|
|
1716
|
+
} catch (e) { return bail(e.code || "E_EVENTS_FAILED", e.message, e.candidates ? { candidates: e.candidates } : undefined); }
|
|
1939
1717
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
//
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
if (
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1718
|
+
if (sub === "stop") {
|
|
1719
|
+
// K3: plan → apply. The plan is what a confirmation shows; apply carries
|
|
1720
|
+
// its revision back and refuses if reality moved.
|
|
1721
|
+
const homeOpt = flag("home");
|
|
1722
|
+
if (homeOpt === true || (homeOpt !== undefined && !isAbsolute(homeOpt))) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
|
|
1723
|
+
let root; try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
|
|
1724
|
+
const recursive = !args.includes("--no-recursive");
|
|
1725
|
+
const wantPlan = args.includes("--plan"), wantApply = args.includes("--apply");
|
|
1726
|
+
if (wantPlan === wantApply) return bail("E_BAD_ARGS", "stop needs exactly one of --plan or --apply");
|
|
1727
|
+
try {
|
|
1728
|
+
if (wantPlan) {
|
|
1729
|
+
const plan = planStop(dirFlag(), root, name, { home: homeOpt, recursive });
|
|
1730
|
+
if (JSON_MODE) { jsonOk(plan); return; }
|
|
1731
|
+
console.log(`stop ${name}${recursive ? " (and recorded children)" : ""} — plan ${plan.planRevision}`);
|
|
1732
|
+
for (const t of plan.targets) console.log(` ${" ".repeat(t.depth)}${t.instance}: session ${t.session.state}${t.work.observed ? `, ${t.work.changed} changed / ${t.work.untracked} untracked on ${t.work.branch ?? "detached"}` : ", work not observed"}${t.midTask === true ? " — mid-task" : t.midTask === "unknown" ? " — activity unknown" : ""}`);
|
|
1733
|
+
for (const n of plan.notes) console.log(` note: ${n}`);
|
|
1734
|
+
console.log(`apply with: oats instance stop ${name} --apply --plan-revision ${plan.planRevision} --idempotency-key <key>`);
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const rev = flag("plan-revision"), key = flag("idempotency-key"), grace = flag("grace-ms");
|
|
1738
|
+
if (rev === true || key === true || grace === true) return bail("E_BAD_ARGS", usage);
|
|
1739
|
+
const receipt = applyStop(dirFlag(), root, name, { home: homeOpt, recursive, planRevision: rev, idempotencyKey: key, ...(grace !== undefined ? { graceMs: Number(grace) } : {}) });
|
|
1740
|
+
if (JSON_MODE) { jsonOk(receipt); return; }
|
|
1741
|
+
for (const r of receipt.results) console.log(` ${r.instance}: ${r.ok ? (r.stopped ? "stopped" : `already ${r.state}`) : `${r.code} — ${r.message}`}`);
|
|
1742
|
+
console.log(receipt.ok ? `stopped${receipt.replayed ? " (replayed receipt)" : ""}; home, work, transcript and launch configuration retained — restart with \`oats session restart\`` : "some targets are still running; nothing was escalated");
|
|
1743
|
+
if (!receipt.ok) process.exit(1);
|
|
1956
1744
|
return;
|
|
1957
|
-
}
|
|
1958
|
-
cmdFail("E_UNKNOWN_CAPABILITY", `unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
|
|
1959
|
-
}
|
|
1960
|
-
if (layer && manifest.layer !== layer) cmdFail("E_LAYER_MISMATCH", `capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
|
|
1961
|
-
const targets = [["agent-types", flag("type")], ["souls", flag("soul")]].filter(([, value]) => value);
|
|
1962
|
-
if (args.includes("--global")) targets.push(["global", undefined]);
|
|
1963
|
-
if (targets.length > 1) cmdFail("E_BAD_ARGS", "choose exactly one of --global, --type, or --soul");
|
|
1964
|
-
const [targetKind, targetName] = targets[0] || ["global", undefined];
|
|
1965
|
-
const targetLabel = targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"}:${targetName}`;
|
|
1966
|
-
const soulForEffective = targetKind === "souls" ? targetName : undefined;
|
|
1967
|
-
const enabled = !args.includes("--disable");
|
|
1968
|
-
const bindingOf = (e) => (!e ? undefined : targetKind === "global" ? e.global : e[targetKind]?.[targetName]);
|
|
1969
|
-
const stateOf = (e) => ({ bound: bindingOf(e) !== undefined, enabled: bindingOf(e) === undefined ? null : (typeof bindingOf(e) === "object" ? bindingOf(e).enabled !== false : !!bindingOf(e)), settings: e?.settings && typeof e.settings === "object" ? { ...e.settings } : {} });
|
|
1970
|
-
// --inherit removes THIS level's binding for the addressed target (and the
|
|
1971
|
-
// whole entry once no target is left), so outer scopes and targets apply
|
|
1972
|
-
// again. Distinct from --disable, which writes an explicit exclusion.
|
|
1973
|
-
if (inherit) {
|
|
1974
|
-
const existing = manifest.layer ? caps.layers[manifest.layer] : caps.additive[manifest.capability];
|
|
1975
|
-
const entry0 = existing && existing !== "none" && (!manifest.layer || existing.capability === manifest.capability) ? existing : undefined;
|
|
1976
|
-
const before = stateOf(entry0);
|
|
1977
|
-
if (!entry0 || !before.bound) cmdFail("E_NOT_BOUND", `${manifest.capability} has no ${targetLabel} binding at ${level} level (${shortPath(file)}); nothing to inherit from`);
|
|
1978
|
-
// Only the addressed target goes. Every other binding the entry carries
|
|
1979
|
-
// (an explicit global, other souls, types) is policy this command cannot
|
|
1980
|
-
// tell from intent, so it stays; the receipt names what still applies.
|
|
1981
|
-
if (targetKind === "global") delete entry0.global;
|
|
1982
|
-
else { delete entry0[targetKind][targetName]; if (!Object.keys(entry0[targetKind]).length) delete entry0[targetKind]; }
|
|
1983
|
-
const remaining = [...(entry0.global !== undefined ? [`global: ${entry0.global}`] : []), ...Object.entries(entry0["agent-types"] || {}).map(([t, v]) => `type:${t}: ${JSON.stringify(v)}`), ...Object.entries(entry0.souls || {}).map(([n, v]) => `soul:${n}: ${JSON.stringify(v)}`)];
|
|
1984
|
-
const targetsLeft = remaining.length > 0;
|
|
1985
|
-
if (!targetsLeft) { if (manifest.layer) delete caps.layers[manifest.layer]; else delete caps.additive[manifest.capability]; }
|
|
1986
|
-
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
1987
|
-
const note = targetsLeft ? `this level still binds ${manifest.capability}: ${remaining.join(", ")}; remove them with oats use ${manifest.capability} --inherit --global|--type <t>|--soul <s> if the intent is full inheritance` : null;
|
|
1988
|
-
answer({ capability: manifest.capability, action: "inherit", target: targetLabel, layer: manifest.layer || null, level, file, entryRemoved: !targetsLeft, remaining, note, before, after: { bound: false, enabled: null, settings: targetsLeft ? before.settings : {}, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) } },
|
|
1989
|
-
`${manifest.capability} ${targetLabel} binding removed at ${level} level${targetsLeft ? `; still bound here: ${remaining.join(", ")}` : " (entry removed)"} (${shortPath(file)})`);
|
|
1990
|
-
return;
|
|
1745
|
+
} catch (e) { return bail(e.code || "E_LIFECYCLE_FAILED", e.message, e.plan ? { plan: e.plan } : e.candidates ? { candidates: e.candidates } : undefined); }
|
|
1991
1746
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
if (
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
if (settingsArgs.length) {
|
|
2020
|
-
entry.settings = entry.settings && typeof entry.settings === "object" ? entry.settings : {};
|
|
2021
|
-
for (const kv of settingsArgs) {
|
|
2022
|
-
const eq = kv.indexOf("=");
|
|
2023
|
-
if (eq <= 0) cmdFail("E_BAD_ARGS", `--settings expects key=value, got "${kv}"`);
|
|
2024
|
-
// WRITE side of the refusals the readers enforce. Two distinct hazards on
|
|
2025
|
-
// this one line:
|
|
2026
|
-
// - `--settings __proto__=x` assigned through the inherited setter,
|
|
2027
|
-
// which swallowed the entry, and the command reported success for a
|
|
2028
|
-
// setting it never wrote;
|
|
2029
|
-
// - the VALUE is rendered verbatim into one `key: value` line, so a
|
|
2030
|
-
// newline-bearing value stopped being a value and became document —
|
|
2031
|
-
// a crafted one added a whole second capability entry.
|
|
2032
|
-
// Both fail closed, before anything is written.
|
|
2033
|
-
const key = assertSafeConfigWriteKey(kv.slice(0, eq), `--settings key ${JSON.stringify(kv.slice(0, eq))}`);
|
|
2034
|
-
entry.settings[key] = assertSafeConfigValue(kv.slice(eq + 1), `--settings value for ${JSON.stringify(key)}`);
|
|
1747
|
+
let home = flag("home");
|
|
1748
|
+
if (home === true) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
|
|
1749
|
+
if (home !== undefined && !isAbsolute(home)) return bail("E_BAD_ARGS", "--home needs an absolute instance home");
|
|
1750
|
+
if (home === undefined) {
|
|
1751
|
+
let root;
|
|
1752
|
+
try { root = ensureRoot(dirFlag()); } catch (e) { return bail(e.code || "E_NO_ROOT", e.message); }
|
|
1753
|
+
let r; try { r = resolveOatsConfig(dirFlag()); } catch (e) { return bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1754
|
+
const roots = [...new Set([root, ...(r.team ? teamAgentRoots(r.team.scope) : [])].map((p) => realOrResolved(p)))];
|
|
1755
|
+
const candidates = [];
|
|
1756
|
+
for (const rt of roots) for (const hit of findInstanceHomes(rt, name)) candidates.push({ root: rt, agent: hit.agent?.name ?? null, home: hit.home });
|
|
1757
|
+
if (!candidates.length) return bail("E_SESSION_UNKNOWN", `no instance ${JSON.stringify(name)} under ${roots.join(", ")}`);
|
|
1758
|
+
if (candidates.length > 1) return bail("E_AMBIGUOUS_INSTANCE", `instance ${JSON.stringify(name)} has ${candidates.length} homes; pass --home <abs>`, { candidates });
|
|
1759
|
+
home = candidates[0].home;
|
|
1760
|
+
} else if (basename(home) !== name) return bail("E_HOME_MISMATCH", `--home ${home} is not the home of instance ${JSON.stringify(name)}`);
|
|
1761
|
+
try {
|
|
1762
|
+
if (sub === "git") {
|
|
1763
|
+
const observed = observeInstanceGit(home);
|
|
1764
|
+
if (JSON_MODE) { jsonOk(observed); return; }
|
|
1765
|
+
const o = observed.observation;
|
|
1766
|
+
console.log(`${observed.instance} — ${shortPath(o.worktree)} @ ${o.branch ?? (o.detached ? `detached ${o.revision.slice(0, 12)}` : "unborn")}`);
|
|
1767
|
+
console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
|
|
1768
|
+
console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
|
|
1769
|
+
console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
|
|
1770
|
+
for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
|
|
1771
|
+
for (const n of observed.notes) console.log(` note: ${n}`);
|
|
1772
|
+
return;
|
|
2035
1773
|
}
|
|
1774
|
+
const fileId = flag("file"), revision = flag("revision"), indexRevision = flag("index-revision");
|
|
1775
|
+
if (fileId === true || revision === true || indexRevision === true) return bail("E_BAD_ARGS", usage);
|
|
1776
|
+
const d = diffInstanceFile(home, { fileId, revision, indexRevision });
|
|
1777
|
+
if (JSON_MODE) { jsonOk(d); return; }
|
|
1778
|
+
console.log(`${d.file.origPath ? `${d.file.origPath} -> ` : ""}${d.file.path} (${d.file.kind}, against ${d.against})${d.binary ? " [binary]" : ""}${d.truncated ? ` [truncated at ${d.limit} bytes]` : ""}`);
|
|
1779
|
+
if (!d.binary) process.stdout.write(d.patch);
|
|
1780
|
+
} catch (e) {
|
|
1781
|
+
bail(e.code || "E_GIT_FAILED", e.message, e.observation ? { observation: e.observation } : undefined);
|
|
2036
1782
|
}
|
|
2037
|
-
if (targetKind === "global") entry.global = enabled;
|
|
2038
|
-
else {
|
|
2039
|
-
// An EXISTING layer entry with no explicit targets is implicitly global:
|
|
2040
|
-
// materialize that before narrowing, so adding a soul/type binding does not
|
|
2041
|
-
// silently drop everyone else. An entry this command just created (the
|
|
2042
|
-
// layer was `none` or another capability) has no implicit global to keep:
|
|
2043
|
-
// a targeted first binding is written as global: false, explicitly.
|
|
2044
|
-
if (manifest.layer && entry.global === undefined && !entry["agent-types"] && !entry.souls) entry.global = entryExisted;
|
|
2045
|
-
entry[targetKind] = entry[targetKind] && typeof entry[targetKind] === "object" ? entry[targetKind] : {};
|
|
2046
|
-
// Same write-side refusal, and for the same two reasons: `--soul
|
|
2047
|
-
// __proto__` was swallowed by the inherited setter and reported as
|
|
2048
|
-
// activated, and a `--soul`/`--type` NAME is written as a mapping key, so a
|
|
2049
|
-
// newline in it injects document exactly like a settings value does.
|
|
2050
|
-
entry[targetKind][assertSafeConfigWriteKey(targetName, `--${targetKind === "agent-types" ? "type" : "soul"} name ${JSON.stringify(String(targetName))}`)] = enabled;
|
|
2051
|
-
}
|
|
2052
|
-
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
2053
|
-
const afterState = stateOf(entry);
|
|
2054
|
-
const missing = capabilityMissingRequires(manifest.capability, dir);
|
|
2055
|
-
answer({ capability: manifest.capability, action: enabled ? "enable" : "disable", target: targetLabel, layer: manifest.layer || null, level, file, settings: entry.settings || {}, before: beforeState, after: { ...afterState, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) }, missingRequires: missing },
|
|
2056
|
-
`${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
|
|
2057
|
-
if (JSON_MODE) return;
|
|
2058
|
-
for (const miss of missing) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
|
|
2059
|
-
console.log("New instances receive the resolved capability; committed souls are unchanged.");
|
|
2060
1783
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
// Usage errors surface BEFORE any restore/network side effect: a malformed
|
|
2072
|
-
// --accept-requirement must not mutate the deployment and then report E_USAGE.
|
|
2073
|
-
flagAll("accept-requirement");
|
|
2074
|
-
reconcile(dir);
|
|
2075
|
-
return;
|
|
2076
|
-
}
|
|
2077
|
-
const retiredReason = retiredCapabilityReason(src);
|
|
2078
|
-
if (retiredReason) cmdFail("retired-capability", retiredReason);
|
|
2079
|
-
// Package source? (git/path with an oats-package.json, or a catalog id) — otherwise legacy capability acquisition.
|
|
2080
|
-
let parsedSrc;
|
|
2081
|
-
try { parsedSrc = parsePackageSource(src); } catch { parsedSrc = undefined; }
|
|
2082
|
-
const catalogId = parsedSrc?.kind === "catalog" ? parsedSrc.id : undefined;
|
|
2083
|
-
const hasOfficialPackage = !!catalogId && Object.hasOwn(officialPackageCatalog(), catalogId);
|
|
2084
|
-
// Once an official package catalog entry exists it becomes the default
|
|
2085
|
-
// acquisition route for that short id. Existing v1 installs keep working,
|
|
2086
|
-
// but a deliberate `oats install oats.okf` now acquires the package rather than
|
|
2087
|
-
// creating another legacy capability lock.
|
|
2088
|
-
const isMarketplaceCap = parsedSrc?.kind === "catalog" && !!marketplaceCapabilities()[catalogId] && !hasOfficialPackage;
|
|
2089
|
-
const isLocalPackage = parsedSrc?.kind === "path" && existsSync(join(parsedSrc.path, "oats-package.json"));
|
|
2090
|
-
const isCatalogPackage = parsedSrc?.kind === "catalog" && !isMarketplaceCap;
|
|
2091
|
-
let gitInspection;
|
|
2092
|
-
if (parsedSrc && (parsedSrc.kind === "git" || isLocalPackage || isCatalogPackage)) {
|
|
2093
|
-
// Remote Git may be either a distribution package or the documented
|
|
2094
|
-
// legacy standalone-capability repository. Inspect the fetched ROOT before
|
|
2095
|
-
// any scope lock preflight; never infer root layout from closure errors.
|
|
2096
|
-
if (parsedSrc.kind === "git") {
|
|
2097
|
-
try { gitInspection = inspectGitSourceRoot(src); }
|
|
2098
|
-
catch (e) { cmdFail(e.code || "invalid-source", e.message || e); return; }
|
|
2099
|
-
if (gitInspection.payloadPackage) {
|
|
2100
|
-
try { installPackage(dir, src, { rootSnapshot: gitInspection }); }
|
|
2101
|
-
finally { gitInspection.cleanup(); }
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
// Legacy standalone-capability repositories predate contained package
|
|
2105
|
-
// roots, so the fallback only applies to a REPOSITORY-ROOT capability
|
|
2106
|
-
// that was not asked for a specific path. A repo whose root carries
|
|
2107
|
-
// oats-package.json must never silently downgrade to capability
|
|
2108
|
-
// acquisition just because the selected path holds no package.
|
|
2109
|
-
if (gitInspection.explicitPath || gitInspection.package || !gitInspection.capability) {
|
|
2110
|
-
const where = `package path "${gitInspection.path}"`;
|
|
2111
|
-
const reason = gitInspection.package
|
|
2112
|
-
? `Git source ${src} has an oats-package.json at the repository ROOT but no package at ${where}${gitInspection.explicitPath ? "" : " (the default)"} — select the root explicitly with \`${src}#.\``
|
|
2113
|
-
: gitInspection.explicitPath
|
|
2114
|
-
? `Git source ${src} has no oats-package.json at ${where}`
|
|
2115
|
-
: `Git source ${src} has no oats-package.json at ${where} (the default package path) and no oats.json at its root`;
|
|
2116
|
-
gitInspection.cleanup();
|
|
2117
|
-
cmdFail("invalid-package-manifest", reason); return;
|
|
2118
|
-
}
|
|
2119
|
-
// Standalone capability: hand the SAME fetched snapshot to legacy
|
|
2120
|
-
// acquisition (which re-verifies that exact root layout before copying).
|
|
2121
|
-
} else { installPackage(dir, src); return; }
|
|
2122
|
-
}
|
|
2123
|
-
let known;
|
|
2124
|
-
try { known = gitInspection ? undefined : capabilityManifest(src, dir); }
|
|
2125
|
-
catch (e) { gitInspection?.cleanup(); cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
2126
|
-
if (known) {
|
|
2127
|
-
if (JSON_MODE) { jsonOk({ alreadyAcquired: known.capability, version: known.version || null }); return; }
|
|
2128
|
-
console.log(`Already acquired capability ${known.capability} (${known.version || "unversioned"}); not activated or updated.`);
|
|
2129
|
-
return;
|
|
1784
|
+
/** `oats readiness [--soul <name> [--agents-root <abs>]] [--home <abs>] [--verify-signatures] [--policy] [--dir <d>] --json` — K5. */
|
|
1785
|
+
function readinessCmd() {
|
|
1786
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
1787
|
+
dropAmbientRoot();
|
|
1788
|
+
// A captured incarnation's readiness comes from its retained resolution, not
|
|
1789
|
+
// from the current configuration this command reads; refuse before inspecting.
|
|
1790
|
+
const homeArg = flag("home");
|
|
1791
|
+
if (homeArg && homeArg !== true) {
|
|
1792
|
+
let capturedMeta = null; try { capturedMeta = JSON.parse(readFileSync(join(String(homeArg), "instance.json"), "utf8")); } catch { /* computeInspect reports the unreadable home */ }
|
|
1793
|
+
if (capturedMeta?.executionBinding || capturedMeta?.captured) return bail("E_UNSUPPORTED_MODE", `${basename(String(homeArg))} is a captured incarnation: its readiness is the retained resolution's, not the current configuration's (inspect it with oats operation --deployment/--resolution)`, { home: String(homeArg), captured: true });
|
|
2130
1794
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
1795
|
+
const inspect = computeInspect({ onFail: bail });
|
|
1796
|
+
if (!inspect) return;
|
|
1797
|
+
const soul = flag("soul") === true ? null : flag("soul") || inspect.selected?.soul || null;
|
|
1798
|
+
const verify = args.includes("--verify-signatures");
|
|
1799
|
+
let catalog = null; try { catalog = describeOfficialCatalog(); catalog = { packages: Object.fromEntries(catalog.packages.map((p) => [p.package, p])) }; } catch { catalog = null; }
|
|
1800
|
+
const deploymentDir = inspect.scope?.context ?? null;
|
|
1801
|
+
// Echo the exact selector this read was made with, so a consumer can bind the
|
|
1802
|
+
// result to its own admitted target without inventing a revision.
|
|
1803
|
+
// Every field is the argument AS GIVEN (no realpath): a consumer compares it
|
|
1804
|
+
// byte-exact with what it sent. The canonical scope is subject.context.
|
|
1805
|
+
const given = (name) => { const v = flag(name); return v && v !== true ? String(v) : null; };
|
|
1806
|
+
const agentsRootArg = given("agents-root"), dirArg = given("dir");
|
|
1807
|
+
const selector = homeArg && homeArg !== true ? { kind: "home", home: String(homeArg), soul, agentsRoot: agentsRootArg }
|
|
1808
|
+
: soul ? { kind: "soul", soul, agentsRoot: agentsRootArg, dir: dirArg }
|
|
1809
|
+
: { kind: "scope", dir: dirArg };
|
|
1810
|
+
const readiness = readinessOf(inspect, { soul, verifySignatures: verify, catalog, deploymentDir, selector });
|
|
1811
|
+
if (args.includes("--policy")) {
|
|
1812
|
+
const homeOpt = flag("home");
|
|
1813
|
+
let meta = null;
|
|
1814
|
+
if (homeOpt && homeOpt !== true) { try { meta = JSON.parse(readFileSync(join(homeOpt, "instance.json"), "utf8")); } catch (e) { return bail("E_SESSION_UNKNOWN", `${homeOpt}: ${e.message}`); } }
|
|
1815
|
+
readiness.policy = policyOf({ instanceMeta: meta, soul: soul ? inspect.souls.find((s) => s.name === soul) : null }).policy;
|
|
1816
|
+
readiness.notes.push("policy: a lifecycle-authority claim enforced by the spawn route, not an OS sandbox");
|
|
2145
1817
|
}
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
// not strand the acquired artifact — compensate before failing.
|
|
2152
|
-
rmSync(r.dest, { recursive: true, force: true });
|
|
2153
|
-
cmdFail(e.code || "legacy-lock", e.message); return;
|
|
2154
|
-
}
|
|
2155
|
-
if (JSON_MODE) { jsonOk({ capability: r.manifest.capability, version: r.manifest.version || null, integrity: r.integrity, source: r.source, dir: r.dest, lockFile, marketplace: !!r.marketplace, trustedExecutables: !!r.marketplace }); return; }
|
|
2156
|
-
console.log(`Acquired ${r.manifest.capability} → ${shortPath(r.dest)}`);
|
|
2157
|
-
console.log(`Locked ${r.manifest.version || r.commit || "exact artifact"} (${r.integrity}) in ${shortPath(lockFile)}; not activated.`);
|
|
2158
|
-
if (r.marketplace) console.log("Marketplace package: executables trusted at acquisition.");
|
|
2159
|
-
else if (r.manifest.commands || r.manifest.hooks || r.manifest.environment?.length) {
|
|
2160
|
-
if (r.manifest.environment?.length) console.log(`Future trust request includes launch environment: ${r.manifest.environment.join(", ")}`);
|
|
2161
|
-
console.log(`Executable surface is blocked until: oats trust ${r.manifest.capability} --dir ${shortPath(dir)}`);
|
|
1818
|
+
if (JSON_MODE) { jsonOk(readiness); return; }
|
|
1819
|
+
console.log(`readiness — ${readiness.subject.kind === "soul" ? `soul ${readiness.subject.name}` : shortPath(readiness.subject.context)}: ${readiness.summary.ready ? "READY" : `${readiness.summary.fail} failing, ${readiness.summary.unknown} unknown of ${readiness.summary.required} required`}`);
|
|
1820
|
+
for (const [name, check] of Object.entries(readiness.checks)) {
|
|
1821
|
+
console.log(` ${name}: ${check.status}`);
|
|
1822
|
+
for (const i of check.items) console.log(` ${i.status.padEnd(14)} ${i.subject}${i.required ? "" : " (optional)"}${i.reason ? ` — ${i.reason}` : ""}${i.signature ? ` · signature ${i.signature.status}${i.signature.signer?.label ? ` by ${i.signature.signer.label}` : ""}` : ""}${i.remedy ? ` → ${i.remedy}` : ""}`);
|
|
2162
1823
|
}
|
|
1824
|
+
if (readiness.policy) console.log(` policy: child spawns ${readiness.policy.childSpawns.allowed ? "allowed" : "disabled"} (${readiness.policy.childSpawns.origin.kind}${readiness.policy.childSpawns.enforced ? ", enforced" : ""}); worktrees ${readiness.policy.worktrees.allowed === null ? "unknown" : readiness.policy.worktrees.allowed ? "allowed" : "not in this work mode"}`);
|
|
1825
|
+
for (const n of readiness.notes) console.log(` note: ${n}`);
|
|
2163
1826
|
}
|
|
1827
|
+
// ---------- workspace model v2: sync / package / workspace status / capabilities / souls ----------
|
|
1828
|
+
// Contract: docs/design/2026-09-23-workspace-module-contracts.md §6. Nothing is
|
|
1829
|
+
// installed: `oats-local.yaml` names the workspace, discovery runs over the Git
|
|
1830
|
+
// remotes (lib/workspace.mjs), packages resolve to exact commits (lib/packages.mjs,
|
|
1831
|
+
// lock v3) and the only persisted state is `oats-lock.json` beside oats-local.yaml.
|
|
2164
1832
|
|
|
2165
|
-
/**
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
if (dirname(d) === d) break;
|
|
2171
|
-
}
|
|
2172
|
-
return levels.reverse();
|
|
1833
|
+
/** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
|
|
1834
|
+
* the content-addressed fetch cache (tests never touch ~/.cache). */
|
|
1835
|
+
function remoteOptionsFromEnv() {
|
|
1836
|
+
const cacheDir = process.env.OATS_REMOTE_CACHE;
|
|
1837
|
+
return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
|
|
2173
1838
|
}
|
|
2174
1839
|
|
|
2175
|
-
/**
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
1840
|
+
/** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
|
|
1841
|
+
function workspaceContext(bail) {
|
|
1842
|
+
const dir = dirFlag();
|
|
1843
|
+
let found;
|
|
1844
|
+
try { found = loadLocal(dir); }
|
|
1845
|
+
catch (e) { return bail(e.code || "E_LOCAL_MISSING", e.message, e.details); }
|
|
1846
|
+
return { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** The official catalog's package map (package-catalog.json; OATS_PACKAGE_CATALOG overrides). */
|
|
1850
|
+
function catalogForSync(bail) {
|
|
1851
|
+
try { return officialPackageCatalog(); }
|
|
1852
|
+
catch (e) { return bail(e.code || "E_PACKAGE_MISSING", e.message); }
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** The package requests of a standalone view: the catalog's own pin of the package
|
|
1856
|
+
* providing oats.core (decision 25) — nothing else, since the version list lives in
|
|
1857
|
+
* the workspace file we cannot read. The request is written as resolvePackages
|
|
1858
|
+
* expects a CATALOG entry: the bare version (`v1.1.3`), never the catalog's raw ref
|
|
1859
|
+
* (`oats-framework/v1.1.3` is a tag PATH the one grammar refuses); parsePackageRequest
|
|
1860
|
+
* recomposes the tag from the catalog's own convention.
|
|
1861
|
+
* → { packages: { <id>: <version> }, problems: [ { code: "E_PACKAGE_MISSING", … } ] } */
|
|
1862
|
+
function standalonePackages(catalog) {
|
|
1863
|
+
let id = "oats.framework", file = process.env.OATS_PACKAGE_CATALOG || null;
|
|
1864
|
+
try { const d = describeOfficialCatalog(); file = d.catalog.file; id = d.capabilityAliases.find((a) => a.capability === "oats.core")?.package ?? id; } catch { /* the catalog is diagnosed below */ }
|
|
1865
|
+
const version = standaloneCatalogVersion(catalog?.[id]?.ref);
|
|
1866
|
+
if (version) return { packages: { [id]: version }, problems: [] };
|
|
1867
|
+
const why = catalog?.[id] ? `its ref ${JSON.stringify(catalog[id].ref)} carries no version` : `it has no entry ${JSON.stringify(id)}`;
|
|
1868
|
+
return { packages: {}, problems: [{ code: "E_PACKAGE_MISSING", id, reason: "no-catalog", catalog: file, path: `/packages/${id}`, message: `the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}): ${why}; standalone spawns will be refused until it does` }] };
|
|
1869
|
+
}
|
|
1870
|
+
/** The bare version of a catalog ref: its LAST path segment when that is a version
|
|
1871
|
+
* (`v1.1.3` → `v1.1.3`; `oats-framework/v1.1.3` → `v1.1.3`; `main` → null). */
|
|
1872
|
+
function standaloneCatalogVersion(ref) {
|
|
1873
|
+
if (typeof ref !== "string" || !ref.trim()) return null;
|
|
1874
|
+
const tail = ref.trim().split("/").filter(Boolean).pop() ?? "";
|
|
1875
|
+
return classifyPackageValue(tail).kind === "catalog" ? tail : null;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Discover the workspace named by oats-local.yaml over the real remote. */
|
|
1879
|
+
async function discoverForCli(ctx, bail) {
|
|
1880
|
+
// The standalone case (decisions 10/25) is a discovery too: the repo's own view
|
|
1881
|
+
// plus the kernel's oats.core default — discoverOrStandalone decides.
|
|
1882
|
+
try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); return await discoverOrStandalone(ctx.local, { remoteOptions: ctx.remoteOptions }); }
|
|
1883
|
+
catch (e) {
|
|
1884
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
|
|
1885
|
+
throw e;
|
|
2211
1886
|
}
|
|
2212
|
-
return byLevel;
|
|
2213
1887
|
}
|
|
2214
1888
|
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
const
|
|
2236
|
-
|
|
2237
|
-
.
|
|
2238
|
-
|
|
2239
|
-
|
|
1889
|
+
const short = (oid) => (typeof oid === "string" ? oid.slice(0, 8) : "?");
|
|
1890
|
+
/** Where a reader takes capability manifests from: the instance home's own
|
|
1891
|
+
* materialized modules when the home has them (workspace model), else the
|
|
1892
|
+
* context directory (classic chain). */
|
|
1893
|
+
const manifestSource = (meta, home, ctx) => (meta && meta.modules && typeof meta.modules === "object" && home ? realOrResolved(home) : ctx);
|
|
1894
|
+
/** Display name of a discovery: the workspace's name, or the standalone label (decision 10). */
|
|
1895
|
+
const workspaceName = (discovery) => discovery.workspace?.name ?? `standalone:${memberLabel(discovery.key)}`;
|
|
1896
|
+
const memberLabel = (key) => String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || String(key);
|
|
1897
|
+
const teamLabel = (team) => team ?? "unassigned";
|
|
1898
|
+
const originOf = (item) => (item.package ? `package ${item.package} v${item.version}` : `member ${item.repoKey} @ ${short(item.commit)}`);
|
|
1899
|
+
|
|
1900
|
+
/** Rows of every non-private soul/capability of confirmed members + locked package capabilities. */
|
|
1901
|
+
function workspaceItems(discovery, lock, { includePrivate = false } = {}) {
|
|
1902
|
+
const souls = [];
|
|
1903
|
+
const capabilities = [];
|
|
1904
|
+
for (const m of discovery.members) {
|
|
1905
|
+
if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
|
|
1906
|
+
for (const s of m.souls) if (includePrivate || !s.private) souls.push({ name: s.name, origin: originOf(s), kind: "member", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
|
|
1907
|
+
for (const c of m.capabilities) if (includePrivate || !c.private) capabilities.push({ name: c.name, origin: originOf(c), kind: "member", repoKey: c.repoKey, commit: c.commit, team: teamLabel(c.team), private: c.private, path: c.path, layer: c.manifest.layer ?? null, version: c.manifest.version ?? null });
|
|
1908
|
+
}
|
|
1909
|
+
for (const ext of discovery.external || []) {
|
|
1910
|
+
const s = ext.soul;
|
|
1911
|
+
if (includePrivate || !s.private) souls.push({ name: s.name, origin: `external ${s.repoKey} @ ${short(s.commit)}`, kind: "external", repoKey: s.repoKey, commit: s.commit, team: teamLabel(s.team), private: s.private, path: s.path, work: s.definition.work ?? null, description: s.definition.description ?? null });
|
|
1912
|
+
}
|
|
1913
|
+
for (const [id, entry] of Object.entries(lock?.packages || {})) {
|
|
1914
|
+
for (const name of entry.capabilities) capabilities.push({ name, origin: originOf({ package: id, version: entry.version }), kind: "package", package: id, version: entry.version, commit: entry.commit, team: teamLabel(null), private: false, approved: !!entry.approved });
|
|
1915
|
+
}
|
|
1916
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
|
|
1917
|
+
souls.sort(byName);
|
|
1918
|
+
capabilities.sort(byName);
|
|
1919
|
+
return { souls, capabilities };
|
|
2240
1920
|
}
|
|
2241
1921
|
|
|
2242
|
-
/**
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
// Fail-closed locks: restorePackages/restoreCapabilities RAISE typed
|
|
2248
|
-
// invalid-lock — let the reconcile boundary surface the code verbatim
|
|
2249
|
-
// (never softened to empty); this throw is caught by reconcile().
|
|
2250
|
-
const pkgReport = restorePackages(dir).map((r) => ({
|
|
2251
|
-
id: r.package, level: r.level, package: true, dir: r.dir,
|
|
2252
|
-
status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
|
|
1922
|
+
/** Membership rows for `sync` / `workspace status`. */
|
|
1923
|
+
function memberRows(discovery) {
|
|
1924
|
+
return discovery.members.map((m) => ({
|
|
1925
|
+
key: m.key, name: memberLabel(m.key), commit: m.commit ?? null, confirmed: m.confirmed, status: m.confirmed ? "confirmed" : m.reason, detail: m.detail ?? null, team: m.team ?? null,
|
|
1926
|
+
souls: m.souls.map((s) => s.name), capabilities: m.capabilities.map((c) => c.name), publishes: m.publishes ?? null,
|
|
2253
1927
|
}));
|
|
2254
|
-
const report = [...restoreCapabilities(dir), ...pkgReport];
|
|
2255
|
-
if (!report.length) note("Nothing to restore — no locked capabilities in the config chain.");
|
|
2256
|
-
let failed = 0;
|
|
2257
|
-
for (const r of report) {
|
|
2258
|
-
const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
|
|
2259
|
-
if (r.status === "present") note(`ok ${what} (${shortPath(r.dir)})`);
|
|
2260
|
-
else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)}${r.integrity ? ` (${r.integrity})` : ""}`);
|
|
2261
|
-
else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
|
|
2262
|
-
else if (r.status === "retired") { failed++; note(`RETIRED ${what} ${r.reason}`); }
|
|
2263
|
-
else { failed++; note(`FAILED ${what} ${r.reason}`); }
|
|
2264
|
-
}
|
|
2265
|
-
return { report, failed };
|
|
2266
1928
|
}
|
|
2267
1929
|
|
|
2268
|
-
/**
|
|
2269
|
-
|
|
2270
|
-
|
|
1930
|
+
/** Package rows for `sync` / `workspace status` from the lock. */
|
|
1931
|
+
function packageRows(lock) {
|
|
1932
|
+
return Object.entries(lock.packages).map(([id, p]) => ({ id, version: p.version, source: p.source, commit: p.commit, integrity: p.integrity, capabilities: p.capabilities, approved: p.approved }));
|
|
1933
|
+
}
|
|
2271
1934
|
|
|
2272
|
-
/**
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
}
|
|
1935
|
+
/** Print a padded table: rows are arrays of strings. */
|
|
1936
|
+
function printTable(header, rows) {
|
|
1937
|
+
const all = [header, ...rows];
|
|
1938
|
+
const widths = header.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? "").length)));
|
|
1939
|
+
for (const r of all) console.log(" " + r.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd());
|
|
1940
|
+
}
|
|
2278
1941
|
|
|
2279
|
-
/**
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
if (JSON_MODE) {
|
|
2286
|
-
if (failures.length) {
|
|
2287
|
-
console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_RECONCILE_FAILED", message: `${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`, details: result } }));
|
|
2288
|
-
process.exit(1);
|
|
2289
|
-
}
|
|
2290
|
-
jsonOk(result);
|
|
2291
|
-
return;
|
|
2292
|
-
}
|
|
2293
|
-
if (failures.length) {
|
|
2294
|
-
console.log("\nFailures by scope:");
|
|
2295
|
-
for (const f of failures) console.log(` ${shortPath(f.scope)}: ${f.id} — ${f.reason}`);
|
|
2296
|
-
die(`${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`);
|
|
2297
|
-
}
|
|
1942
|
+
/** Executables digest of a locked package, read over the remote at its locked commit. */
|
|
1943
|
+
async function lockedExecutablesDigest(id, entry, workspace, catalog, remoteOptions) {
|
|
1944
|
+
const req = parsePackageRequest(id, workspace.packages[id], catalog);
|
|
1945
|
+
const tree = await readPackageTree(remoteModule, req.remoteRef, entry.commit, entry.path, { remoteOptions });
|
|
1946
|
+
const targets = tree.manifests.flatMap((m) => manifestExecutables(m.manifest).map((x) => `${m.name}: ${x.kind} ${x.name} → ${x.target}`));
|
|
1947
|
+
return { digest: executablesDigest(tree), targets };
|
|
2298
1948
|
}
|
|
2299
1949
|
|
|
2300
|
-
/**
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
*
|
|
2311
|
-
|
|
2312
|
-
|
|
1950
|
+
/** One yes/no question on the terminal (TTY only; the caller checks). */
|
|
1951
|
+
async function askYesNo(question) {
|
|
1952
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1953
|
+
try {
|
|
1954
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
1955
|
+
return answer === "y" || answer === "yes";
|
|
1956
|
+
} finally { rl.close(); }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** The body of `oats sync` — shared by `sync` and `onboard` (which onboards, then syncs the same
|
|
1960
|
+
* way). Given a v2 deployment context: discover over the remotes, confirm membership, resolve
|
|
1961
|
+
* `packages:` against the lock, approve (TTY) or list what needs approval, write the lock.
|
|
1962
|
+
* `bail` never returns (it exits the process with the caller's error shape).
|
|
1963
|
+
* → { report, lock, discovery, approvalNeeded, interactive, items, lockFile } */
|
|
1964
|
+
async function performSync(ctx, bail, { onDiscovered } = {}) {
|
|
1965
|
+
const catalog = catalogForSync(bail);
|
|
1966
|
+
const discovery = await discoverForCli(ctx, bail);
|
|
1967
|
+
onDiscovered?.(discovery);
|
|
1968
|
+
let previous;
|
|
1969
|
+
try { previous = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
1970
|
+
let resolved;
|
|
1971
|
+
// Standalone: no workspace file → the only package request is the kernel's
|
|
1972
|
+
// default, at the catalog's pinned version (decision 25). A catalog that cannot
|
|
1973
|
+
// name it is a PROBLEM of this sync (reported, exit unchanged), not a silent empty lock.
|
|
1974
|
+
const standalone = discovery.standalone === true ? standalonePackages(catalog) : null;
|
|
1975
|
+
const packageSource = standalone ? { packages: standalone.packages } : discovery.workspace;
|
|
1976
|
+
const problems = [...discovery.problems, ...(standalone?.problems ?? [])];
|
|
1977
|
+
try { resolved = await resolvePackages(packageSource, { catalog, lock: previous, remoteOptions: ctx.remoteOptions }); }
|
|
2313
1978
|
catch (e) {
|
|
2314
|
-
if (
|
|
2315
|
-
|
|
2316
|
-
|
|
1979
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
|
|
1980
|
+
throw e;
|
|
1981
|
+
}
|
|
1982
|
+
let lock = resolved.lock;
|
|
1983
|
+
const approvalNeeded = [];
|
|
1984
|
+
const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
|
|
1985
|
+
for (const id of Object.keys(lock.packages)) {
|
|
1986
|
+
const entry = lock.packages[id];
|
|
1987
|
+
if (entry.approved) continue;
|
|
1988
|
+
let digest, targets;
|
|
1989
|
+
try { ({ digest, targets } = await lockedExecutablesDigest(id, entry, packageSource, catalog, ctx.remoteOptions)); }
|
|
1990
|
+
catch (e) {
|
|
1991
|
+
if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance);
|
|
1992
|
+
throw e;
|
|
2317
1993
|
}
|
|
2318
|
-
|
|
1994
|
+
if (interactive) {
|
|
1995
|
+
console.error(`\n${id} ${entry.version} @ ${short(entry.commit)} needs executable approval (${targets.length} executable${targets.length === 1 ? "" : "s"}, digest ${digest}):`);
|
|
1996
|
+
for (const t of targets) console.error(` ${t}`);
|
|
1997
|
+
if (targets.length === 0) console.error(" (no commands or hooks — nothing runs unattended)");
|
|
1998
|
+
if (await askYesNo(`approve ${id} ${entry.version}? [y/N] `)) { lock = approvePackage(lock, id, digest); continue; }
|
|
1999
|
+
}
|
|
2000
|
+
approvalNeeded.push({ id, version: entry.version, commit: entry.commit, executables: digest, targets });
|
|
2319
2001
|
}
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
const
|
|
2324
|
-
const
|
|
2325
|
-
const
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
const
|
|
2346
|
-
const
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
const
|
|
2350
|
-
const
|
|
2351
|
-
const
|
|
2352
|
-
|
|
2353
|
-
const
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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() {
|
|
2002
|
+
let lockFile;
|
|
2003
|
+
try { lockFile = writeLock(ctx.deploymentDir, lock); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
2004
|
+
const members = memberRows(discovery);
|
|
2005
|
+
const packages = packageRows(lock);
|
|
2006
|
+
const changes = resolved.changes;
|
|
2007
|
+
const items = workspaceItems(discovery, lock, { includePrivate: true });
|
|
2008
|
+
const report = { syncApi: 1, standalone: discovery.standalone === true || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, lock: lockFile }, members, packages, changes, approvalNeeded, problems };
|
|
2009
|
+
return { report, lock, discovery, approvalNeeded, interactive, items, lockFile, problems };
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/** The human §8 report of a sync (text mode). */
|
|
2013
|
+
function printSyncReport(ctx, synced) {
|
|
2014
|
+
const { report, discovery, approvalNeeded, interactive, items, lockFile } = synced;
|
|
2015
|
+
const { members, packages, changes } = report;
|
|
2016
|
+
const disabled = new Set(ctx.local.souls?.disabled || []);
|
|
2017
|
+
console.log(`workspace ${discovery.workspace?.name ?? `(standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)`} (${discovery.key} @ ${short(discovery.commit)})`);
|
|
2018
|
+
console.log(`members ${members.map((m) => m.confirmed ? `${m.name} ✓↔ (@ ${short(m.commit)})` : `${m.name} ✗ (${m.status})`).join(" ") || "(none)"}`);
|
|
2019
|
+
console.log(`packages ${packages.map((p) => {
|
|
2020
|
+
const need = approvalNeeded.find((a) => a.id === p.id);
|
|
2021
|
+
return `${p.id} ${p.version} ✓ (${p.approved ? "approved" : need ? "approval needed" : "unapproved"})`;
|
|
2022
|
+
}).join(" ") || "(none)"}`);
|
|
2023
|
+
const changed = changes.filter((c) => c.to !== null && c.from !== c.to).map((c) => `${c.id} ${c.from ?? "—"} → ${c.to} (@ ${short(c.commit)})`);
|
|
2024
|
+
const removed = changes.filter((c) => c.to === null).map((c) => `${c.id} ${c.from} → removed`);
|
|
2025
|
+
console.log(`changed ${[...changed, ...removed].join(" ") || "(nothing — the lock already described this workspace)"}`);
|
|
2026
|
+
const memberSouls = items.souls.filter((s) => s.kind === "member");
|
|
2027
|
+
const externalSouls = items.souls.filter((s) => s.kind === "external");
|
|
2028
|
+
const privateSouls = memberSouls.filter((s) => s.private);
|
|
2029
|
+
const disabledHere = items.souls.filter((s) => disabled.has(s.name));
|
|
2030
|
+
console.log(`souls ${items.souls.length} discovered (${memberSouls.length} members, ${externalSouls.length} external, ${disabledHere.length} disabled here) · ${privateSouls.length} private${privateSouls.length ? ` (${privateSouls.map((s) => `${s.name}, ${memberLabel(s.repoKey)} only`).join("; ")})` : ""}`);
|
|
2031
|
+
const teams = new Map();
|
|
2032
|
+
for (const s of items.souls) { const t = teams.get(s.team) || { souls: 0, capabilities: 0 }; t.souls++; teams.set(s.team, t); }
|
|
2033
|
+
for (const c of items.capabilities.filter((c) => c.kind === "member")) { const t = teams.get(c.team) || { souls: 0, capabilities: 0 }; t.capabilities++; teams.set(c.team, t); }
|
|
2034
|
+
console.log(`teams ${[...teams.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([team, n]) => `${team} ${n.souls} soul${n.souls === 1 ? "" : "s"}${n.capabilities ? `, ${n.capabilities} capabilit${n.capabilities === 1 ? "y" : "ies"}` : ""}`).join(" · ") || "(none)"}`);
|
|
2035
|
+
for (const p of synced.problems ?? discovery.problems) console.log(`problem ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`);
|
|
2036
|
+
if (approvalNeeded.length) {
|
|
2037
|
+
console.log(`\nApproval needed for ${approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")} — ${interactive ? "declined; " : "not a terminal; "}the lock records them unapproved. Run \`oats sync\` in a terminal to approve their executables (spawns of souls using them are refused until then).`);
|
|
2038
|
+
} else console.log(`\nlock ${shortPath(lockFile)}`);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/** `oats sync [--dir] [--json]` — contract §6. */
|
|
2042
|
+
async function syncCmd() {
|
|
3225
2043
|
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
//
|
|
3229
|
-
|
|
3230
|
-
if (
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
}
|
|
3263
|
-
if (
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
if (
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
}
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
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; }
|
|
2044
|
+
const ctx = workspaceContext(bail);
|
|
2045
|
+
const synced = await performSync(ctx, bail);
|
|
2046
|
+
// One envelope (or the §8 report), then the exit status: 2 = the lock is written but approvals
|
|
2047
|
+
// are pending. exitCode (not process.exit) lets stdout drain when it is a pipe.
|
|
2048
|
+
if (JSON_MODE) jsonOk(synced.report); else printSyncReport(ctx, synced);
|
|
2049
|
+
process.exitCode = synced.approvalNeeded.length ? 2 : 0;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** Walk up from dir for oats-workspace.yaml INSIDE a Git checkout → { file, root } | null. */
|
|
2053
|
+
function workspaceCheckoutFrom(dir) {
|
|
2054
|
+
let current = resolve(dir);
|
|
2055
|
+
for (;;) {
|
|
2056
|
+
const candidate = join(current, "oats-workspace.yaml");
|
|
2057
|
+
if (existsSync(candidate)) {
|
|
2058
|
+
// The file is edited in place only when it is TRACKED by the checkout it sits in (an untracked
|
|
2059
|
+
// copy inside some repository is not the shared workspace file).
|
|
2060
|
+
let inCheckout = false;
|
|
2061
|
+
for (let d = current; ; d = dirname(d)) { if (existsSync(join(d, ".git"))) { inCheckout = true; break; } if (dirname(d) === d) break; }
|
|
2062
|
+
if (!inCheckout) return null;
|
|
2063
|
+
const tracked = spawnSync("git", ["-C", current, "ls-files", "--error-unmatch", "--", "oats-workspace.yaml"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10000 });
|
|
2064
|
+
return tracked.status === 0 ? { file: candidate, root: current } : null;
|
|
2065
|
+
}
|
|
2066
|
+
const parent = dirname(current);
|
|
2067
|
+
if (parent === current) return null;
|
|
2068
|
+
current = parent;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** `oats package add <id> <version|git:<repo>@<ref>> | remove <id> [--dir]` — contract §6. */
|
|
2073
|
+
async function packageCmd() {
|
|
2074
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
2075
|
+
const sub = args[1];
|
|
2076
|
+
const id = args[2];
|
|
2077
|
+
const value = sub === "add" && typeof args[3] === "string" && !args[3].startsWith("--") ? args[3] : undefined;
|
|
2078
|
+
const usage = "usage: oats package add <id> <version|git:<repo>@<ref>> [--dir <d>] | oats package remove <id> [--dir <d>]";
|
|
2079
|
+
if (!["add", "remove"].includes(sub) || !id || id.startsWith("--")) return bail("E_USAGE", usage);
|
|
2080
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) return bail("E_WORKSPACE_SCHEMA", `package id ${JSON.stringify(id)} must match ^[a-z0-9][a-z0-9._-]*$`, { id });
|
|
2081
|
+
if (sub === "add") {
|
|
2082
|
+
if (!value || value.startsWith("--")) return bail("E_USAGE", usage);
|
|
2083
|
+
const c = classifyPackageValue(value);
|
|
2084
|
+
if (c.problem) return bail("E_WORKSPACE_SCHEMA", `packages.${id}: ${c.problem} (a package is a bare version like v2.1.3 or git:<repo>@<ref>)`, { id, value });
|
|
2085
|
+
if (c.kind === "git") { try { remoteModule.parseRepoRef(c.repo); } catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); } }
|
|
2086
|
+
}
|
|
2087
|
+
const line = sub === "add" ? ` ${id}: ${YAML.stringify(value).trim()}` : null;
|
|
2088
|
+
const checkout = workspaceCheckoutFrom(dirFlag());
|
|
2089
|
+
if (!checkout) {
|
|
2090
|
+
// Untracked branch (the workspace file is shared through Git, not edited here). A `remove`
|
|
2091
|
+
// still checks the id against the DISCOVERED workspace when this directory realizes one
|
|
2092
|
+
// (oats-local.yaml): removing what is not declared is E_PACKAGE_MISSING, as in the tracked branch.
|
|
2093
|
+
if (sub === "remove") {
|
|
2094
|
+
let ctx = null; try { const found = loadLocal(dirFlag()); ctx = { dir: dirFlag(), localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() }; }
|
|
2095
|
+
catch (e) { if (e?.code !== "E_LOCAL_MISSING") return bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
|
|
2096
|
+
if (ctx) {
|
|
2097
|
+
const discovery = await discoverForCli(ctx, bail);
|
|
2098
|
+
const declared = discovery.standalone === true ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {});
|
|
2099
|
+
if (!Object.hasOwn(declared, id)) return bail("E_PACKAGE_MISSING", `packages.${id} is not declared by workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${discovery.standalone === true ? " — standalone: only the kernel's default package is requested" : ""}`, { id, workspace: discovery.key, declared: Object.keys(declared).sort() });
|
|
2100
|
+
}
|
|
3688
2101
|
}
|
|
3689
|
-
if (JSON_MODE) { jsonOk({
|
|
3690
|
-
if (
|
|
3691
|
-
|
|
3692
|
-
for (const w of warnings) console.log(`WARNING: ${w}`);
|
|
3693
|
-
return;
|
|
3694
|
-
}
|
|
3695
|
-
let r;
|
|
3696
|
-
try { r = applyLegacyLockMigration(dir); }
|
|
3697
|
-
catch (e) {
|
|
3698
|
-
const oas = detectOasScopes(dir);
|
|
3699
|
-
const oasNote = oas.length ? ` NOTE: un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}` : "";
|
|
3700
|
-
cmdFail(e.code || "legacy-lock", `${e.message || e}${oasNote}`); return;
|
|
3701
|
-
}
|
|
3702
|
-
if (JSON_MODE) { jsonOk(r); return; }
|
|
3703
|
-
for (const m of r.migrated) console.log(`migrated ${m.capability} → package ${m.package}@${m.version}`);
|
|
3704
|
-
for (const w of r.warnings) console.log(`WARNING: ${w}`);
|
|
3705
|
-
if (r.formatConverted) { console.log(`${shortPath(r.file)} was an empty lockfileVersion 1 file — converted to canonical v2.`); return; }
|
|
3706
|
-
if (r.file) console.log(`${shortPath(r.file)} is now lockfileVersion 2. Config activation (from: installed) is unchanged; re-run \`oats trust\` for executable capabilities — package integrity approvals are not carried over.`);
|
|
3707
|
-
}
|
|
3708
|
-
|
|
3709
|
-
/** oats update <package> — transactional package update with diff + trust reset. */
|
|
3710
|
-
function updatePackageCmd(id) {
|
|
3711
|
-
const dir = dirFlag();
|
|
3712
|
-
// --to <selector>: move a catalog-sourced lock to another catalog ref
|
|
3713
|
-
// (tag) through the same transactional update. A lock with an explicit
|
|
3714
|
-
// selector keeps it on a plain update by design; this is the operator's
|
|
3715
|
-
// way to advance it without remove + reinstall.
|
|
3716
|
-
// Two spellings: `oats update <id> <id>@<selector>` (the engine's own spec
|
|
3717
|
-
// form) or `oats update <id> --to <selector>`.
|
|
3718
|
-
const to = flag("to");
|
|
3719
|
-
if (to === true) { cmdFail("E_BAD_ARGS", "--to needs a catalog selector, e.g. --to v1.10.1"); return; }
|
|
3720
|
-
if (to !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(to)) { cmdFail("E_BAD_ARGS", `--to selector ${JSON.stringify(to)} is not a catalog ref`); return; }
|
|
3721
|
-
const positional = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
|
|
3722
|
-
if (positional && to !== undefined) { cmdFail("E_BAD_ARGS", "give either <id>@<selector> or --to <selector>, not both"); return; }
|
|
3723
|
-
const spec = positional || (to !== undefined ? `${id}@${to}` : undefined);
|
|
3724
|
-
let r;
|
|
3725
|
-
try { r = updatePackage(dir, id, spec ? { spec } : {}); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
3726
|
-
if (JSON_MODE) { jsonOk(r); return; }
|
|
3727
|
-
// A moved package root is reported even when the bytes are identical: the
|
|
3728
|
-
// lock now points somewhere else in the repository, and that is exactly the
|
|
3729
|
-
// change an operator must see (contract §7).
|
|
3730
|
-
const pathLine = () => console.log(` package path ${r.before.path} → ${r.after.path} (the selected package root MOVED in the source)`);
|
|
3731
|
-
if (!r.changed) {
|
|
3732
|
-
console.log(`${r.package} is already up to date (${r.after.version}, ${r.after.integrity}).`);
|
|
3733
|
-
if (r.pathChanged) pathLine();
|
|
2102
|
+
if (JSON_MODE) { jsonOk({ action: sub, id, value: value ?? null, edited: false, file: null, line: sub === "add" ? `packages:\n${line}` : null, hint: "oats-workspace.yaml is not in this checkout; commit the change in the workspace repo, then `oats sync`" }); return; }
|
|
2103
|
+
if (sub === "add") console.log(`oats-workspace.yaml is not in this checkout. Add under packages: in the workspace repo, then \`oats sync\`:\n\npackages:\n${line}`);
|
|
2104
|
+
else console.log(`oats-workspace.yaml is not in this checkout. Remove \`${id}\` from packages: in the workspace repo, then \`oats sync\`.`);
|
|
3734
2105
|
return;
|
|
3735
2106
|
}
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
if (
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
* the config-chain walk cannot see it), otherwise by acquiring the official
|
|
3752
|
-
* PACKAGE that supplies it through the materialization engine. Acquisition is
|
|
3753
|
-
* not activation, not executable trust and not requirement consent, and the
|
|
3754
|
-
* whole run is one transaction that rolls back on any failure.
|
|
3755
|
-
*/
|
|
3756
|
-
/** Resolve a template (name via outer-config `templates:` maps, local path, or git URL's
|
|
3757
|
-
* main-branch oats-config.yaml) into snapshot text with a provenance comment. */
|
|
3758
|
-
function loadTemplateConfig(spec, dir) {
|
|
3759
|
-
// THROWS typed errors rather than exiting: `oats init --template` reports
|
|
3760
|
-
// through the same single JSON envelope as every other init form.
|
|
3761
|
-
const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
|
|
3762
|
-
let source = spec;
|
|
3763
|
-
const isDirect = /^(https?:\/\/|git@|ssh:\/\/)/.test(spec) || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~");
|
|
3764
|
-
if (!isDirect) {
|
|
3765
|
-
let named;
|
|
3766
|
-
for (const cfg of configChain(dir)) {
|
|
3767
|
-
if (cfg.templates?.[spec]) { named = { value: cfg.templates[spec], level: cfg._level }; break; }
|
|
3768
|
-
}
|
|
3769
|
-
if (!named) fail("E_UNKNOWN_TEMPLATE", `unknown template "${spec}" — declare it under templates: in an outer oats-config.yaml, or pass a path/git URL`);
|
|
3770
|
-
source = /^(https?:\/\/|git@|ssh:\/\/)/.test(named.value) || named.value.startsWith("/") || named.value.startsWith("~")
|
|
3771
|
-
? named.value : resolve(named.level, named.value);
|
|
3772
|
-
}
|
|
3773
|
-
let body, provenance;
|
|
3774
|
-
if (/^(https?:\/\/|git@|ssh:\/\/)/.test(source)) {
|
|
3775
|
-
const tmp = mkdtempSync(join(tmpdir(), "oats-template-"));
|
|
3776
|
-
try {
|
|
3777
|
-
execFileSync("git", ["clone", "-q", "--depth", "1", source, tmp], { stdio: "inherit" });
|
|
3778
|
-
const cfgFile = join(tmp, "oats-config.yaml");
|
|
3779
|
-
if (!existsSync(cfgFile)) fail("E_TEMPLATE_SOURCE", `template repo has no oats-config.yaml on its default branch: ${source}`);
|
|
3780
|
-
body = readFileSync(cfgFile, "utf8");
|
|
3781
|
-
const commit = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
3782
|
-
provenance = `${source}@${commit.slice(0, 12)}`;
|
|
3783
|
-
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
2107
|
+
const text = readFileSync(checkout.file, "utf8");
|
|
2108
|
+
let doc;
|
|
2109
|
+
try { doc = YAML.parseDocument(text, { keepSourceTokens: true }); } catch (e) { return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${e.message}`, { path: checkout.file }); }
|
|
2110
|
+
if (doc.errors?.length) return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: ${doc.errors.map((e) => e.message).join("; ")}`, { path: checkout.file });
|
|
2111
|
+
const root = doc.contents;
|
|
2112
|
+
if (!YAML.isMap(root)) return bail("E_WORKSPACE_SCHEMA", `${checkout.file}: top level must be a mapping`, { path: checkout.file, problems: [{ path: "", message: "top level must be a mapping" }] });
|
|
2113
|
+
const packagesNode = root.get("packages", true);
|
|
2114
|
+
if (packagesNode !== undefined && packagesNode !== null && !(YAML.isScalar(packagesNode) && packagesNode.value === null) && !YAML.isMap(packagesNode)) {
|
|
2115
|
+
return bail("E_WORKSPACE_SCHEMA", `${shortPath(checkout.file)}: packages: must be a mapping of <package-id>: <version>, found ${YAML.isSeq(packagesNode) ? "a sequence" : JSON.stringify(packagesNode.toJSON?.() ?? String(packagesNode))}`, { path: checkout.file, problems: [{ path: "/packages", message: "must be an object" }] });
|
|
2116
|
+
}
|
|
2117
|
+
const previous = YAML.isMap(packagesNode) ? packagesNode.toJSON() : null;
|
|
2118
|
+
const had = previous && Object.hasOwn(previous, id) ? previous[id] : undefined;
|
|
2119
|
+
if (sub === "add") {
|
|
2120
|
+
if (!YAML.isMap(packagesNode)) root.set("packages", doc.createNode({ [id]: value }));
|
|
2121
|
+
else packagesNode.set(id, value);
|
|
3784
2122
|
} else {
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
if (/^templates:\s*$/.test(line)) { skipping = true; continue; }
|
|
3799
|
-
if (skipping) { if (/^\S/.test(line) && line.trim()) skipping = false; else continue; }
|
|
3800
|
-
// Replacer FUNCTION, not a replacement string — this is a WRITE, so a
|
|
3801
|
-
// directory named `x$&y` would otherwise persist a corrupted `name:` line
|
|
3802
|
-
// (`name: xname: template-namey`).
|
|
3803
|
-
out.push(line.replace(/^name:.*$/, () => `name: ${scaffoldName}`));
|
|
3804
|
-
}
|
|
3805
|
-
return `# template: ${provenance} (snapshot — later template edits do not propagate)\n${out.join("\n").replace(/\n*$/, "\n")}`;
|
|
2123
|
+
if (had === undefined) return bail("E_PACKAGE_MISSING", `packages.${id} is not in ${shortPath(checkout.file)}`, { id, path: checkout.file });
|
|
2124
|
+
root.get("packages").delete(id);
|
|
2125
|
+
if (root.get("packages")?.items?.length === 0) root.delete("packages");
|
|
2126
|
+
}
|
|
2127
|
+
const next = doc.toString();
|
|
2128
|
+
const problems = validateWorkspace(YAML.parse(next), { remote: remoteModule });
|
|
2129
|
+
if (problems.length) return bail("E_WORKSPACE_SCHEMA", `${shortPath(checkout.file)} would be invalid: ${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}`, { path: checkout.file, problems });
|
|
2130
|
+
writeFileAtomic(checkout.file, next);
|
|
2131
|
+
const receipt = { action: sub, id, value: value ?? null, previous: had ?? null, edited: true, file: checkout.file };
|
|
2132
|
+
if (JSON_MODE) { jsonOk(receipt); return; }
|
|
2133
|
+
console.log(sub === "add"
|
|
2134
|
+
? `${had === undefined ? "Added" : `Changed (${had} →)`} packages.${id}: ${value} in ${shortPath(checkout.file)}. Commit it, then \`oats sync\` (the lock resolves the version to a commit and asks approval once).`
|
|
2135
|
+
: `Removed packages.${id} (${had}) from ${shortPath(checkout.file)}. Commit it, then \`oats sync\`.`);
|
|
3806
2136
|
}
|
|
3807
2137
|
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
const
|
|
3811
|
-
|
|
3812
|
-
const
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
const
|
|
3817
|
-
|
|
3818
|
-
//
|
|
3819
|
-
//
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
return;
|
|
3859
|
-
}
|
|
3860
|
-
if (JSON_MODE) { jsonOk({ file, level: levelOf(dir), raw, adopted: false, template, acquired: [], activated, requirements: [] }); return; }
|
|
3861
|
-
offerTmuxMouseScrolling();
|
|
3862
|
-
return;
|
|
3863
|
-
}
|
|
3864
|
-
if (template === true) { bail("E_USAGE", "--template needs a name, local config path, or git URL"); return; }
|
|
3865
|
-
|
|
3866
|
-
// Per-layer overrides: --knowledge oats.okf, --messaging none, --tasks oats.jira …
|
|
3867
|
-
const overrides = {};
|
|
3868
|
-
const market = marketplaceCapabilities();
|
|
3869
|
-
// Own-scope manifests are read DIRECTLY: no oats-config.yaml exists here yet,
|
|
3870
|
-
// so the config-chain walk cannot see this scope's own store, and a
|
|
3871
|
-
// capability already installed here would look unknown.
|
|
3872
|
-
// Object.assign onto a null prototype, never an object spread: `{ ...map }`
|
|
3873
|
-
// re-plainifies the null-prototype sources, and `mans[v]` is then indexed with
|
|
3874
|
-
// a `--<layer>` value the operator typed — `--knowledge constructor` would
|
|
3875
|
-
// read Object.prototype.constructor as a manifest and report a layer mismatch
|
|
3876
|
-
// for a capability that does not exist.
|
|
3877
|
-
const mans = Object.assign(Object.create(null), market, capabilityManifests(dir), ownScopeCapabilityManifests(dir));
|
|
3878
|
-
for (const layer of LAYERS) {
|
|
3879
|
-
const v = flag(layer);
|
|
3880
|
-
if (v === undefined) continue;
|
|
3881
|
-
if (v === true || String(v).startsWith("--")) bail("E_USAGE", `--${layer} needs a canonical capability ID or "none"`);
|
|
3882
|
-
if (v !== "none") {
|
|
3883
|
-
// Known locally: its declared layer is checkable right now, before any
|
|
3884
|
-
// mutation. Otherwise the official catalog may still supply it, and the
|
|
3885
|
-
// layer is verified against the MATERIALIZED manifest after acquisition —
|
|
3886
|
-
// inside the run transaction, so a disagreement rolls the whole run back.
|
|
3887
|
-
if (mans[v]) {
|
|
3888
|
-
if (mans[v].layer !== layer) bail("E_LAYER_MISMATCH", `capability "${v}" declares layer "${mans[v].layer || "none"}", not "${layer}"`);
|
|
3889
|
-
} else if (!officialCapabilityPackage(v).available) {
|
|
3890
|
-
bail("E_UNKNOWN_CAPABILITY", `unknown capability "${v}" for --${layer} — it is not acquired at ${shortPath(dir)}, not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it (catalog: ${Object.keys(officialPackageCatalog()).join(", ") || "empty"})`);
|
|
3891
|
-
}
|
|
3892
|
-
}
|
|
3893
|
-
overrides[layer] = v;
|
|
3894
|
-
}
|
|
3895
|
-
|
|
3896
|
-
const defaults = raw
|
|
3897
|
-
? { knowledge: "none", messaging: "none", tasks: "none" }
|
|
3898
|
-
: { knowledge: "oats.okf", messaging: "oats.aweb", tasks: undefined };
|
|
3899
|
-
let layers = { ...defaults, ...overrides };
|
|
3900
|
-
|
|
3901
|
-
// Interactive TTY with no explicit layer flags: present each default and ask.
|
|
3902
|
-
// Non-interactive contexts (agents, CI) keep flags-or-silent-defaults — never hang.
|
|
3903
|
-
if (!raw && !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY && !Object.keys(overrides).length) {
|
|
3904
|
-
const byLayer = (l) => Object.values(mans).filter((m) => m.layer === l).map((m) => m.capability);
|
|
3905
|
-
console.log("Fundamental layers for this scope — Enter keeps the default, or type a capability id / \"none\":");
|
|
3906
|
-
const ask = (prompt) => {
|
|
3907
|
-
process.stdout.write(prompt);
|
|
3908
|
-
const buffer = Buffer.alloc(256);
|
|
3909
|
-
let length = 0;
|
|
3910
|
-
try { length = readSync(process.stdin.fd, buffer, 0, buffer.length); } catch { /* EOF */ }
|
|
3911
|
-
return buffer.subarray(0, length).toString("utf8").trim();
|
|
3912
|
-
};
|
|
3913
|
-
for (const layer of LAYERS) {
|
|
3914
|
-
const options = byLayer(layer);
|
|
3915
|
-
const def = layers[layer] || "none";
|
|
3916
|
-
while (true) {
|
|
3917
|
-
const answer = ask(` ${layer.padEnd(10)} [${def}] (options: ${[...options, "none"].join(", ")}): `);
|
|
3918
|
-
if (!answer) break;
|
|
3919
|
-
if (answer === "none" || options.includes(answer)) { layers[layer] = answer; break; }
|
|
3920
|
-
console.log(` unknown "${answer}" — pick one of: ${[...options, "none"].join(", ")}`);
|
|
3921
|
-
}
|
|
3922
|
-
}
|
|
3923
|
-
if ((layers.messaging || "none") !== "none") console.log(" (messaging via aweb: after init, run `oats aweb setup` for guided onboarding)");
|
|
3924
|
-
}
|
|
3925
|
-
// ---- Everything below MUTATES. It is ONE run-level transaction: the config
|
|
3926
|
-
// file, the lock, the flat capability artifacts, the capability .gitignore
|
|
3927
|
-
// and any `.agents` anchor this run creates roll back together. A capability
|
|
3928
|
-
// that was already installed at this scope before the run is restored
|
|
3929
|
-
// byte-identically — this run only ever undoes its own changes. ----
|
|
3930
|
-
let journal;
|
|
3931
|
-
try { journal = beginRunJournal(dir); }
|
|
3932
|
-
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
|
|
3933
|
-
const abort = (e, code) => {
|
|
3934
|
-
const report = journal.rollback();
|
|
3935
|
-
bail(code || e.code || "E_INIT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
|
|
3936
|
-
};
|
|
3937
|
-
|
|
3938
|
-
const acquisitions = [];
|
|
3939
|
-
let resolved;
|
|
3940
|
-
const lines = [
|
|
3941
|
-
`name: ${scaffoldConfigName(dir)}`,
|
|
3942
|
-
"",
|
|
3943
|
-
"# ── Agent types (families) — declared here by name (or via `oats type add`);",
|
|
3944
|
-
"# each soul opts in via `type: <name>` in its soul.yaml. Capability entries can target them.",
|
|
3945
|
-
"# agent-types:",
|
|
3946
|
-
"# reviewers:",
|
|
3947
|
-
"# description: Agents that review changes",
|
|
3948
|
-
"",
|
|
3949
|
-
"capabilities:",
|
|
3950
|
-
" # Fundamental layers — exclusive slots; a capability entry or an explicit none.",
|
|
3951
|
-
" layers:",
|
|
3952
|
-
];
|
|
3953
|
-
try {
|
|
3954
|
-
for (const layer of LAYERS) {
|
|
3955
|
-
const selected = layers[layer];
|
|
3956
|
-
if (!selected) { lines.push(` # ${layer}: (unset — inherits from outer config scopes; set an entry or "none")`); continue; }
|
|
3957
|
-
if (selected === "none") { lines.push(` ${layer}: none`); continue; }
|
|
3958
|
-
// Already here (own scope first — see above), or acquired now.
|
|
3959
|
-
const manifest = ownScopeCapabilityManifest(dir, selected)
|
|
3960
|
-
|| capabilityManifest(selected, dir)
|
|
3961
|
-
|| acquireLayerCapability(dir, selected, layer, acquisitions, note);
|
|
3962
|
-
lines.push(` ${layer}:`);
|
|
3963
|
-
lines.push(` capability: ${manifest.capability}`);
|
|
3964
|
-
if (String(manifest._origin).startsWith("installed:")) { lines.push(" from: installed"); lines.push(` # injection-override: .agents/injections/capabilities/${manifest.capability}.md`); }
|
|
3965
|
-
else if (String(manifest._origin).startsWith("owned:")) { lines.push(" from: owned"); lines.push(` # injection edited at source: .agents/capabilities/owned/${manifest.capability}/injects/`); }
|
|
3966
|
-
}
|
|
3967
|
-
lines.push(
|
|
3968
|
-
" # Additive capabilities — non-exclusive; target global, agent-types, or souls.",
|
|
3969
|
-
" # additive:",
|
|
3970
|
-
" # <capability-id>:",
|
|
3971
|
-
" # from: installed",
|
|
3972
|
-
" # global: true",
|
|
3973
|
-
" # # injection-override: .agents/injections/capabilities/<capability-id>.md",
|
|
3974
|
-
"",
|
|
3975
|
-
"# ── Work modes — optional per-mode env bootstrap.",
|
|
3976
|
-
"# `setup:` runs inside each NEW worktree right after `git worktree add` — use it",
|
|
3977
|
-
"# for env setup scripts (installs, .env copying, direnv, mise, etc.).",
|
|
3978
|
-
"# The path is relative to this config's directory.",
|
|
3979
|
-
"work-modes:",
|
|
3980
|
-
" worktree:",
|
|
3981
|
-
" # setup: scripts/setup-worktree.sh",
|
|
3982
|
-
"",
|
|
3983
|
-
"# ── OATS defaults — the framework's baseline instruction block.",
|
|
3984
|
-
"oats:",
|
|
3985
|
-
" # injection-override: .agents/injections/oats-defaults/oats.md",
|
|
3986
|
-
);
|
|
3987
|
-
writeFileSync(file, lines.join("\n") + "\n");
|
|
3988
|
-
// Resolve INSIDE the transaction: a config this run wrote that cannot
|
|
3989
|
-
// resolve is a broken scope, so it fails the init and rolls back rather
|
|
3990
|
-
// than being left behind for the next command to trip over.
|
|
3991
|
-
resolved = resolveOatsConfig(dir);
|
|
3992
|
-
journal.finalize();
|
|
3993
|
-
} catch (e) { abort(e); return; }
|
|
3994
|
-
|
|
3995
|
-
note(`Created ${shortPath(file)} (${levelOf(dir)} level${raw ? ", raw" : ""})`);
|
|
3996
|
-
// Acquisition is not activation, not executable trust, and not requirement
|
|
3997
|
-
// consent — say so per acquisition rather than implying the layer is ready.
|
|
3998
|
-
for (const a of acquisitions) {
|
|
3999
|
-
if (!a.executableSurface.length) continue;
|
|
4000
|
-
note(`Executable surfaces from ${a.package || "the marketplace"} are blocked until trusted: ${a.executableSurface.map((c) => `oats trust ${c}`).join("; ")}`);
|
|
4001
|
-
}
|
|
4002
|
-
|
|
4003
|
-
const r = resolved;
|
|
4004
|
-
const activated = [];
|
|
4005
|
-
for (const cap of r.capabilities) {
|
|
4006
|
-
activated.push({ capability: cap.id, layer: cap.layer || null });
|
|
4007
|
-
note(`Activated: ${cap.id}${cap.layer ? ` → ${cap.layer}` : ""}`);
|
|
4008
|
-
for (const miss of cap.missingRequires) note(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
|
|
4009
|
-
}
|
|
4010
|
-
if (JSON_MODE) {
|
|
4011
|
-
jsonOk({
|
|
4012
|
-
file, level: levelOf(dir), raw, adopted: false,
|
|
4013
|
-
layers: Object.fromEntries(LAYERS.map((l) => [l, layers[l] ?? null])),
|
|
4014
|
-
acquired: acquisitions, activated,
|
|
4015
|
-
// Same facts the human run prints, in the same run: who asked, why, and
|
|
4016
|
-
// the ONE copyable command that consents to installing it. Init never
|
|
4017
|
-
// runs it — reporting a requirement and acting on it are separate steps,
|
|
4018
|
-
// and an agent reading this envelope must be able to tell them apart.
|
|
4019
|
-
requirements: r.capabilities.flatMap((c) => c.missingRequires.map((m) => ({
|
|
4020
|
-
capability: c.id, command: m.command, why: m.why || null, install: m.install || null,
|
|
4021
|
-
consentCommand: `oats install --accept-requirement ${m.command} --dir ${shellQuote(dir)}`,
|
|
4022
|
-
}))),
|
|
4023
|
-
});
|
|
4024
|
-
return;
|
|
4025
|
-
}
|
|
4026
|
-
offerTmuxMouseScrolling();
|
|
2138
|
+
/** `oats workspace status [--dir] [--json]` — contract §6. */
|
|
2139
|
+
async function workspaceCmd() {
|
|
2140
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
2141
|
+
if (args[1] !== "status") return bail("E_USAGE", "usage: oats workspace status [--dir <d>] [--json]");
|
|
2142
|
+
const ctx = workspaceContext(bail);
|
|
2143
|
+
const discovery = await discoverForCli(ctx, bail);
|
|
2144
|
+
let lock;
|
|
2145
|
+
try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
2146
|
+
const members = memberRows(discovery);
|
|
2147
|
+
const packages = packageRows(lock);
|
|
2148
|
+
// Standalone (decision 10): there is no workspace file — the declared packages are the
|
|
2149
|
+
// kernel's own default (decision 25) and there are no teams.
|
|
2150
|
+
const standalone = discovery.standalone === true;
|
|
2151
|
+
const declared = Object.keys(standalone ? standalonePackages(catalogForSync(bail)).packages : (discovery.workspace?.packages || {})).sort();
|
|
2152
|
+
const locked = new Set(packages.map((p) => p.id));
|
|
2153
|
+
const unsynced = declared.filter((id) => !locked.has(id));
|
|
2154
|
+
const stale = packages.filter((p) => !declared.includes(p.id)).map((p) => p.id);
|
|
2155
|
+
const approval = { approved: packages.filter((p) => p.approved).map((p) => p.id), needed: packages.filter((p) => !p.approved).map((p) => p.id) };
|
|
2156
|
+
const result = { workspaceStatusApi: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, url: discovery.url, commit: discovery.commit, observedAt: discovery.observedAt, local: ctx.localPath, teams: Object.keys(discovery.workspace?.teams || {}) }, members, packages, declaredPackages: declared, unsynced, stale, approval, external: (discovery.external || []).map((e) => ({ source: e.source, soul: e.soul.name, team: teamLabel(e.soul.team) })), problems: discovery.problems };
|
|
2157
|
+
if (JSON_MODE) { jsonOk(result); return; }
|
|
2158
|
+
console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
|
|
2159
|
+
if (standalone) console.log(` (standalone — the workspace of ${memberLabel(discovery.key)} cannot be read; its own souls + oats.core)\n`);
|
|
2160
|
+
console.log("Members:");
|
|
2161
|
+
printTable(["member", "status", "commit", "team", "souls", "capabilities", "publishes"], members.map((m) => [m.name, m.status, short(m.commit), m.team ?? "—", m.souls.join(",") || "—", m.capabilities.join(",") || "—", m.publishes ? `${m.publishes.package} v${m.publishes.version ?? "?"}` : "—"]));
|
|
2162
|
+
for (const m of members.filter((m) => !m.confirmed)) console.log(` ${m.name}: ${m.detail}`);
|
|
2163
|
+
console.log("\nPackages:");
|
|
2164
|
+
if (!packages.length) console.log(unsynced.length ? ` (none locked yet — \`oats sync\` resolves ${unsynced.join(", ")})` : " (none)");
|
|
2165
|
+
else printTable(["package", "version", "source", "commit", "approval", "capabilities"], packages.map((p) => [p.id, p.version, p.source, short(p.commit), p.approved ? `approved ${p.approved.at.slice(0, 10)}` : "NEEDED", p.capabilities.join(",")]));
|
|
2166
|
+
if (packages.length && unsynced.length) console.log(` declared but not locked (run \`oats sync\`): ${unsynced.join(", ")}`);
|
|
2167
|
+
if (stale.length) console.log(` locked but no longer declared (run \`oats sync\`): ${stale.join(", ")}`);
|
|
2168
|
+
if (result.external.length) console.log(`\nExternal: ${result.external.map((e) => `${e.soul} (${e.source.replace(/@([0-9a-f]{40})$/, (_, o) => `@${short(o)}`)}, ${e.team})`).join(" ")}`);
|
|
2169
|
+
if (discovery.problems.length) { console.log("\nProblems:"); for (const p of discovery.problems) console.log(` ${p.code} ${p.repoKey ? `${memberLabel(p.repoKey)}:` : ""}${p.path} ${p.message}`); }
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** `oats capabilities` / `oats souls` [--dir] [--json] — contract §6. */
|
|
2173
|
+
async function itemsCmd(kind) {
|
|
2174
|
+
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
2175
|
+
const ctx = workspaceContext(bail);
|
|
2176
|
+
const discovery = await discoverForCli(ctx, bail);
|
|
2177
|
+
let lock;
|
|
2178
|
+
try { lock = readLock(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
|
|
2179
|
+
const items = workspaceItems(discovery, lock)[kind];
|
|
2180
|
+
const standalone = discovery.standalone === true;
|
|
2181
|
+
if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
|
|
2182
|
+
console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — standalone: the workspace of ${memberLabel(discovery.key)} cannot be read` : ""}\n`);
|
|
2183
|
+
if (!items.length) console.log(" (none)");
|
|
2184
|
+
else if (kind === "souls") printTable(["name", "origin", "team", "work"], items.map((s) => [s.name, s.origin, s.team, s.work ?? "—"]));
|
|
2185
|
+
else printTable(["name", "origin", "team", "layer"], items.map((c) => [c.name, c.origin, c.team, c.layer ?? "—"]));
|
|
2186
|
+
const unsynced = Object.keys(discovery.workspace?.packages || {}).filter((id) => !lock.packages[id]);
|
|
2187
|
+
if (kind === "capabilities" && unsynced.length) console.log(`\n package capabilities of ${unsynced.join(", ")} appear after \`oats sync\``);
|
|
4027
2188
|
}
|
|
4028
2189
|
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
*
|
|
4032
|
-
*
|
|
4033
|
-
*
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
const manifest = ownScopeCapabilityManifest(dir, capId);
|
|
4050
|
-
if (!manifest) fail("E_LAYER_UNREADABLE", `capability "${capId}" was materialized but its manifest under ${shortPath(installedCapabilityDir(dir, capId))} is unreadable`);
|
|
4051
|
-
if (manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
|
|
4052
|
-
const executableSurface = acq.capabilities
|
|
4053
|
-
.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
|
|
4054
|
-
.map((c) => c.capability);
|
|
4055
|
-
acquired.push({
|
|
4056
|
-
layer, capability: capId, route: "package", package: official.package, via: official.via,
|
|
4057
|
-
packages: acq.installed.map((p) => ({ package: p.package, version: p.version || null, commit: p.commit || null })),
|
|
4058
|
-
lockFile: acq.lockFile, trusted: false, executableSurface,
|
|
4059
|
-
});
|
|
4060
|
-
note(`Acquired package ${official.package} for the ${layer} layer → ${capId} (${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")}) → ${shortPath(acq.lockFile)}`);
|
|
4061
|
-
return { ...manifest, _origin: `installed:${dir}` };
|
|
2190
|
+
// ---------- roster: status / spawn / retire / create ----------
|
|
2191
|
+
/** Workspace drift for `oats status` (decision 17: shown, not prevented). ONE discovery over
|
|
2192
|
+
* the remotes serves every instance; `driftOf` compares each instance's recorded modules to
|
|
2193
|
+
* the members' current state (and package modules to the lock). Offline → { unreachable }.
|
|
2194
|
+
* Returns null when the deployment is not a workspace deployment (no oats-local.yaml). */
|
|
2195
|
+
async function statusDrift(data) {
|
|
2196
|
+
let ctx;
|
|
2197
|
+
try { ctx = loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") return null; throw e; }
|
|
2198
|
+
const hasModules = data.some((a) => (a.instances || []).some((i) => i.modules && typeof i.modules === "object" && Object.keys(i.modules).length));
|
|
2199
|
+
if (!hasModules) return { drift: new Map(), unreachable: null };
|
|
2200
|
+
const deploymentDir = dirname(ctx.path);
|
|
2201
|
+
let lock = null;
|
|
2202
|
+
try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
|
|
2203
|
+
let discovery;
|
|
2204
|
+
// The standalone view (decisions 10/25) is a discovery too: drift of a standalone
|
|
2205
|
+
// instance is computed against its member's current state, not reported "unreachable".
|
|
2206
|
+
try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { remoteOptions: remoteOptionsFromEnv() }); }
|
|
2207
|
+
catch (e) {
|
|
2208
|
+
const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
|
|
2209
|
+
return { drift: new Map(), unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
|
|
4062
2210
|
}
|
|
4063
|
-
const
|
|
4064
|
-
|
|
4065
|
-
|
|
2211
|
+
const { driftOf } = await import("../lib/materialize.mjs");
|
|
2212
|
+
const drift = new Map();
|
|
2213
|
+
for (const a of data) for (const i of a.instances || []) {
|
|
2214
|
+
if (!i.modules || typeof i.modules !== "object" || !Object.keys(i.modules).length) continue;
|
|
2215
|
+
try { drift.set(i.home ?? `${a.name}/${i.instance}`, driftOf(i, discovery, { lock })); } catch { /* an unreadable module record shows as no drift rows */ }
|
|
4066
2216
|
}
|
|
4067
|
-
|
|
4068
|
-
// packages, ship with the kernel already installed, and keep their v1 lock and
|
|
4069
|
-
// acquisition-time trust until the catalog covers them.
|
|
4070
|
-
const r = acquireCapability(dir, capId);
|
|
4071
|
-
try {
|
|
4072
|
-
writeCapabilityLock(dir, r.manifest.capability, {
|
|
4073
|
-
source: r.source, version: r.manifest.version || null, integrity: r.integrity, trustedExecutables: true,
|
|
4074
|
-
});
|
|
4075
|
-
} catch (e) { rmSync(r.dest, { recursive: true, force: true }); throw e; }
|
|
4076
|
-
if (r.manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${r.manifest.layer || "none"}", not "${layer}"`);
|
|
4077
|
-
acquired.push({ layer, capability: capId, route: "marketplace", package: null, via: "marketplace", packages: [], lockFile: join(dir, OATS_LOCK_FILE), trusted: true, executableSurface: [] });
|
|
4078
|
-
note(`Acquired ${r.manifest.capability}@${r.manifest.version} from the marketplace → ${shortPath(r.dest)}`);
|
|
4079
|
-
return { ...r.manifest, _origin: `installed:${dir}` };
|
|
2217
|
+
return { drift, unreachable: null };
|
|
4080
2218
|
}
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
function ownScopeCapabilityManifests(dir) {
|
|
4090
|
-
// Capability-id keyed — never answer for `constructor`/`toString`. Belt and
|
|
4091
|
-
// braces on the write side (store directory names are identity-validated at
|
|
4092
|
-
// acquisition); it matters on the read side, where `oats init` indexes this
|
|
4093
|
-
// map with a `--<layer>` flag value the operator typed.
|
|
4094
|
-
const out = Object.create(null);
|
|
4095
|
-
for (const [sub, origin] of [[installedCapabilitiesDir(dir), "installed"], [ownedCapabilitiesDir(dir), "owned"]]) {
|
|
4096
|
-
if (!existsSync(sub)) continue;
|
|
4097
|
-
let entries;
|
|
4098
|
-
try { entries = readdirSync(sub, { withFileTypes: true }); } catch { continue; }
|
|
4099
|
-
for (const e of entries) {
|
|
4100
|
-
// Dot-prefixed entries are transaction staging, never installed content.
|
|
4101
|
-
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
4102
|
-
let raw;
|
|
4103
|
-
try { raw = JSON.parse(readFileSync(join(sub, e.name, "oats.json"), "utf8")); } catch { continue; }
|
|
4104
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
4105
|
-
// Strip BEFORE the spread. `_dir` and `_origin` are reassigned just after
|
|
4106
|
-
// it, but every OTHER annotation in the namespace — `_capabilityLock`,
|
|
4107
|
-
// `_package`, `_soulDir` … — would flow straight out of an
|
|
4108
|
-
// artifact-controlled document. That is the exact shape the kernel's own
|
|
4109
|
-
// manifest reader was fixed for, and this map is merged OVER that
|
|
4110
|
-
// stripped one, so leaving it raw kept the forgery carrier alive.
|
|
4111
|
-
const m = stripInternalAnnotations(raw);
|
|
4112
|
-
if (typeof m.capability === "string") out[m.capability] = { ...m, _dir: join(sub, e.name), _origin: `${origin}:${dir}` };
|
|
4113
|
-
}
|
|
4114
|
-
}
|
|
4115
|
-
return out;
|
|
2219
|
+
/** One `modules:` line per module. */
|
|
2220
|
+
function driftLine(row) {
|
|
2221
|
+
const from = row.from || {};
|
|
2222
|
+
const origin = from.kind === "package" ? `package ${from.package} v${from.version}` : memberLabel(row.recorded?.repoKey ?? from.repoKey ?? "?");
|
|
2223
|
+
const base = `modules: ${row.module} from ${origin} @ ${short7(row.recorded?.commit)}`;
|
|
2224
|
+
if (row.status === "moved") return `${base} [${from.kind === "package" ? "package" : "member"} moved since (now @ ${short7(row.current?.commit)})]`;
|
|
2225
|
+
if (row.status === "missing") return `${base} [${row.reason === "capability-absent" ? "capability no longer present" : row.reason === "package-absent" ? "package no longer locked" : `member ${row.reason || "unconfirmed"}`}]`;
|
|
2226
|
+
return base;
|
|
4116
2227
|
}
|
|
4117
|
-
const
|
|
2228
|
+
const short7 = (oid) => (typeof oid === "string" ? oid.slice(0, 7) : "?");
|
|
4118
2229
|
|
|
4119
|
-
|
|
4120
|
-
function status() {
|
|
2230
|
+
async function status() {
|
|
4121
2231
|
if (args.includes("--team")) return statusTeam();
|
|
4122
|
-
|
|
2232
|
+
let root;
|
|
2233
|
+
try { root = ensureRoot(dirFlag()); }
|
|
2234
|
+
catch (e) { if (e?.code === "E_NO_DEPLOYMENT") { if (JSON_MODE) jsonFail("E_NO_DEPLOYMENT", e.message, e.details ?? e.provenance); die(e.message); } throw e; }
|
|
4123
2235
|
const data = listInstances(root);
|
|
4124
|
-
|
|
2236
|
+
const ws = await statusDrift(data);
|
|
2237
|
+
const verbose = args.includes("--verbose");
|
|
2238
|
+
if (args.includes("--json")) {
|
|
2239
|
+
if (ws) for (const a of data) for (const i of a.instances || []) { const rows = ws.drift.get(i.home ?? `${a.name}/${i.instance}`); if (rows) i.modules = rows.map((r) => ({ name: r.module, from: r.from, commit: r.recorded?.commit ?? null, current: r.current, status: r.status, ...(r.reason ? { reason: r.reason } : {}) })); }
|
|
2240
|
+
console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}) }, null, 2)); return;
|
|
2241
|
+
}
|
|
4125
2242
|
console.log(`oats status — agents root ${shortPath(root)}\n`);
|
|
2243
|
+
if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
|
|
4126
2244
|
if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
|
|
4127
2245
|
for (const a of data) {
|
|
4128
2246
|
console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
|
|
4129
2247
|
if (a.description) console.log(` ${a.description}`);
|
|
4130
2248
|
for (const i of a.instances) {
|
|
4131
2249
|
console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
|
|
2250
|
+
const rows = ws?.drift.get(i.home ?? `${a.name}/${i.instance}`) || [];
|
|
2251
|
+
for (const r of rows) if (verbose || r.status !== "current") console.log(` ${driftLine(r)}`);
|
|
4132
2252
|
}
|
|
4133
2253
|
for (const f of a.retireFailures || []) {
|
|
4134
2254
|
console.log(` ! deferred retirement of ${f.instance} FAILED${f.completedAt ? ` at ${f.completedAt}` : ""}: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
|
|
@@ -4159,7 +2279,7 @@ function statusTeam() {
|
|
|
4159
2279
|
}
|
|
4160
2280
|
}
|
|
4161
2281
|
|
|
4162
|
-
function spawnCmd() {
|
|
2282
|
+
async function spawnCmd() {
|
|
4163
2283
|
// JSON mode: contract envelope, stable error codes, stderr-only progress.
|
|
4164
2284
|
const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
|
|
4165
2285
|
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
@@ -4195,6 +2315,52 @@ function spawnCmd() {
|
|
|
4195
2315
|
root = hit.root;
|
|
4196
2316
|
}
|
|
4197
2317
|
let agent = findAgent(root, name);
|
|
2318
|
+
// Workspace model: with an oats-local.yaml the soul is ALWAYS discovered over the
|
|
2319
|
+
// remotes and resolved (member = latest state, package = locked+approved) — never
|
|
2320
|
+
// "whatever <agents-root>/<name>/soul/ happens to hold": that copy is a per-commit
|
|
2321
|
+
// cache (ensureWorkspaceSoul refreshes it when the member moved), so a second
|
|
2322
|
+
// spawn sees the member's CURRENT soul, not the first spawn's. A preview runs the
|
|
2323
|
+
// same read-only discovery+resolution; the fetched soul copy it may leave under
|
|
2324
|
+
// <agents-root>/<name>/soul/ is not an instance (reported as soulFetched).
|
|
2325
|
+
const providerPairs = [];
|
|
2326
|
+
for (let i = 0; i < args.length; i++) if (args[i] === "--provider") { if (!args[i + 1] || !args[i + 2]) bail("E_BAD_ARGS", "--provider needs <capability> <key>=<value>"); providerPairs.push([args[i + 1], args[i + 2]]); i += 2; }
|
|
2327
|
+
let wsPrepared, soulFetched = false, wsSoulUnknown = null;
|
|
2328
|
+
let hasLocal = true;
|
|
2329
|
+
{
|
|
2330
|
+
try { loadLocal(dirFlag()); } catch (e) { if (e?.code === "E_LOCAL_MISSING") hasLocal = false; else bail(e.code || "E_WORKSPACE_SCHEMA", e.message, e.details); }
|
|
2331
|
+
if (hasLocal) {
|
|
2332
|
+
let discovery = null;
|
|
2333
|
+
try {
|
|
2334
|
+
const { prepareInstance, ensureWorkspaceSoul, parseProviderFlags, discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
|
|
2335
|
+
const remoteOptions = remoteOptionsFromEnv();
|
|
2336
|
+
const { local } = loadLocal(dirFlag());
|
|
2337
|
+
discovery = await discoverOrStandalone(local, { remoteOptions });
|
|
2338
|
+
wsPrepared = await prepareInstance(dirFlag(), name, { spawn: { providers: parseProviderFlags(providerPairs) }, remoteOptions, discovery });
|
|
2339
|
+
const soulName = wsPrepared.soulEntry.name;
|
|
2340
|
+
const stampFile = join(root, soulName, ".oats-soul-source.json");
|
|
2341
|
+
const stampBefore = (() => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } })();
|
|
2342
|
+
const soulDir = await ensureWorkspaceSoul(wsPrepared, root);
|
|
2343
|
+
soulFetched = !stampBefore || stampBefore.commit !== wsPrepared.soulEntry.commit || stampBefore.repoKey !== wsPrepared.soulEntry.repoKey;
|
|
2344
|
+
if (!agent || soulFetched || agent._dir !== dirname(soulDir)) agent = findAgent(root, soulName);
|
|
2345
|
+
if (!agent) bail("E_SOUL_UNKNOWN", `soul "${name}" was fetched to ${shortPath(soulDir)} but is not readable as a soul there`);
|
|
2346
|
+
note(`(workspace soul: "${name}" from ${wsPrepared.soulEntry.repoKey} @ ${String(wsPrepared.soulEntry.commit).slice(0, 12)}${wsPrepared.soulEntry.team ? `, team ${wsPrepared.soulEntry.team}` : ""}${soulFetched ? "; soul source fetched" : ""})`);
|
|
2347
|
+
} catch (e) {
|
|
2348
|
+
// Standalone (decisions 10/25): the ONLY package request is the kernel's own
|
|
2349
|
+
// default; when the catalog cannot name it, say so instead of "add it to packages:"
|
|
2350
|
+
// (there is no workspace file to add it to).
|
|
2351
|
+
if (e?.code === "E_PACKAGE_MISSING" && discovery?.standalone === true) {
|
|
2352
|
+
let file = process.env.OATS_PACKAGE_CATALOG || null; try { file = describeOfficialCatalog().catalog.file; } catch { /* keep the env value */ }
|
|
2353
|
+
bail(e.code, `${e.details?.capability ?? "oats.core"}: the catalog has no package providing oats.core (OATS_PACKAGE_CATALOG=${file ?? "<bundled>"}) — standalone spawns resolve only the kernel's default package from the catalog`, { ...(e.details ?? {}), standalone: true, reason: "no-catalog", catalog: file });
|
|
2354
|
+
}
|
|
2355
|
+
// Not a workspace soul: a capability-defined agent (a module's `agents:`
|
|
2356
|
+
// soul, resolved below from a materialized copy) or a local-only soul
|
|
2357
|
+
// (--instructions-file/--def-file) may still answer to this name.
|
|
2358
|
+
if (e?.code === "E_SOUL_UNKNOWN" && !isPreview) { wsSoulUnknown = e; }
|
|
2359
|
+
else if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details);
|
|
2360
|
+
else throw e;
|
|
2361
|
+
}
|
|
2362
|
+
} else if (providerPairs.length) bail("E_BAD_ARGS", "--provider needs a workspace deployment (oats-local.yaml); this directory has none");
|
|
2363
|
+
}
|
|
4198
2364
|
if (agentsRootFlag !== undefined && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not at agents root ${String(agentsRootFlag)}`);
|
|
4199
2365
|
if (isPreview && !agent) bail("E_SOUL_UNKNOWN", `soul "${name}" is not in ${shortPath(root)}; a preview never creates or imports a soul (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"})`);
|
|
4200
2366
|
if (isPreview && (flag("instructions-file") !== undefined || flag("def-file") !== undefined)) bail("E_BAD_ARGS", "--preview does not take --instructions-file/--def-file: a preview never writes a soul");
|
|
@@ -4208,6 +2374,33 @@ function spawnCmd() {
|
|
|
4208
2374
|
note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
|
|
4209
2375
|
}
|
|
4210
2376
|
}
|
|
2377
|
+
if (!agent && !instrFile && !defFile && hasLocal) {
|
|
2378
|
+
// Workspace model: the agent is declared by a capability some INSTANCE already
|
|
2379
|
+
// materialized (the --parent home first, then any home under this root) —
|
|
2380
|
+
// OKF's memory-harvest worker spawned by a knowledge source, for example.
|
|
2381
|
+
const anchorName = flag("parent") || flag("relative-to");
|
|
2382
|
+
const anchorHome = anchorName ? (findInstanceHome(root, String(anchorName)) ?? null) : null;
|
|
2383
|
+
let modAgent;
|
|
2384
|
+
try { modAgent = findModuleCapabilityAgent(root, name, { anchorHome }); }
|
|
2385
|
+
catch (e) { bail(e.code || "E_CAPABILITY_BROKEN", e.message, e.details); }
|
|
2386
|
+
if (modAgent) {
|
|
2387
|
+
agent = modAgent;
|
|
2388
|
+
note(`(capability agent: "${name}" from ${modAgent.capability}, materialized in ${shortPath(modAgent._manifestSource)} — fresh soul, instances home locally)`);
|
|
2389
|
+
} else {
|
|
2390
|
+
// No instance carries it: resolve from the deployment's LOCK — an approved
|
|
2391
|
+
// package whose capability declares agents/<name> is fetched into the
|
|
2392
|
+
// deployment's module store and read from there.
|
|
2393
|
+
try {
|
|
2394
|
+
const { resolvePackageCapabilityAgent } = await import("../lib/instance-resolution.mjs");
|
|
2395
|
+
const hit = await resolvePackageCapabilityAgent(dirFlag(), name, { remoteOptions: remoteOptionsFromEnv(), catalog: (() => { try { return officialPackageCatalog(); } catch { return null; } })() });
|
|
2396
|
+
if (hit) {
|
|
2397
|
+
agent = capabilityAgentFromDir(hit.dir, name, root, { module: { from: { kind: "package", package: hit.package, version: hit.version, commit: hit.commit } } });
|
|
2398
|
+
if (agent) note(`(capability agent: "${name}" from ${hit.capability} — package ${hit.package} v${hit.version}, fetched to ${shortPath(hit.dir)} — fresh soul, instances home locally)`);
|
|
2399
|
+
}
|
|
2400
|
+
} catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
if (!agent && wsSoulUnknown && !instrFile && !defFile) bail(wsSoulUnknown.code, wsSoulUnknown.message, wsSoulUnknown.details);
|
|
4211
2404
|
if (!agent && !instrFile && !defFile) {
|
|
4212
2405
|
// Cross-repo lookup: the soul may live in a sibling repo of the team scope.
|
|
4213
2406
|
// Unique match wins; the instance homes with its owning repo's agents root.
|
|
@@ -4303,11 +2496,26 @@ function spawnCmd() {
|
|
|
4303
2496
|
}
|
|
4304
2497
|
if (wake && (typeof wake.message !== "string" || !wake.message.trim())) bail("E_SCHEDULE_INVALID", "wake message: non-empty text is required");
|
|
4305
2498
|
} catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
|
|
2499
|
+
// Workspace model: when this deployment has an oats-local.yaml, the soul's
|
|
2500
|
+
// capabilities are resolved over the workspace's remotes (member = latest,
|
|
2501
|
+
// package = locked+approved) and copied whole into the new home. Without one
|
|
2502
|
+
// (a bare agents root, tests) the classic soul-directory spawn proceeds.
|
|
2503
|
+
let prepared;
|
|
2504
|
+
if (wsPrepared) {
|
|
2505
|
+
try {
|
|
2506
|
+
const { toCapabilityRows, modulesPreview } = await import("../lib/instance-resolution.mjs");
|
|
2507
|
+
prepared = wsPrepared;
|
|
2508
|
+
prepared.capabilityRows = []; // filled after materialization (paths live in the home); preview uses modulesPreview
|
|
2509
|
+
prepared.preview = modulesPreview(prepared.resolution, root, agent.name);
|
|
2510
|
+
prepared.toCapabilityRows = toCapabilityRows;
|
|
2511
|
+
} catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message, e.details); throw e; }
|
|
2512
|
+
}
|
|
4306
2513
|
let r;
|
|
4307
2514
|
try {
|
|
4308
2515
|
if (args.includes("--allow-child-spawns") && args.includes("--no-child-spawns")) bail("E_BAD_ARGS", "--allow-child-spawns and --no-child-spawns contradict");
|
|
4309
2516
|
if (flag("base") === true) bail("E_BAD_ARGS", "--base needs a ref");
|
|
4310
|
-
|
|
2517
|
+
{ const spawnOpts = {
|
|
2518
|
+
prepared,
|
|
4311
2519
|
purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
|
|
4312
2520
|
...(args.includes("--allow-child-spawns") ? { allowChildSpawns: true } : args.includes("--no-child-spawns") ? { allowChildSpawns: false } : {}),
|
|
4313
2521
|
// Directory execution uses deployment configuration, not an ambient Git
|
|
@@ -4326,8 +2534,16 @@ function spawnCmd() {
|
|
|
4326
2534
|
...(flag("expect-decision") !== undefined && flag("expect-decision") !== true ? { expectDecision: String(flag("expect-decision")) } : {}),
|
|
4327
2535
|
// K6c: with --idempotency-key, a retry of the SAME confirmed decision replays the recorded home instead of spawning twice.
|
|
4328
2536
|
...(flag("idempotency-key") !== undefined && flag("idempotency-key") !== true ? { idempotencyKey: String(flag("idempotency-key")) } : {}),
|
|
4329
|
-
}
|
|
4330
|
-
|
|
2537
|
+
};
|
|
2538
|
+
r = prepared ? await spawnInstanceAsync(root, agent, spawnOpts) : spawnInstance(root, agent, spawnOpts); }
|
|
2539
|
+
if (args.includes("--preview")) {
|
|
2540
|
+
// A workspace preview may have fetched the soul's SOURCE under <agents-root>/<name>/soul/
|
|
2541
|
+
// (a per-commit cache, not an instance): the result says so.
|
|
2542
|
+
if (prepared) r.soulFetched = soulFetched;
|
|
2543
|
+
if (JSON_MODE) { jsonOk(r); return; }
|
|
2544
|
+
console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) runtime ${r.runtime}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? ` (the soul source was fetched to ${shortPath(join(root, r.agent, "soul"))} — a per-commit copy, not an instance)` : ""}`);
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
4331
2547
|
} catch (e) {
|
|
4332
2548
|
// A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
|
|
4333
2549
|
// (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
|
|
@@ -4604,143 +2820,141 @@ async function paneCmd() {
|
|
|
4604
2820
|
die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
|
|
4605
2821
|
}
|
|
4606
2822
|
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
2823
|
+
/** `oats onboard [<dir>] --workspace <repo ref> [--json]` — workspace model v2 (decision 9).
|
|
2824
|
+
*
|
|
2825
|
+
* Realizes a workspace on this machine in the taught `<name>-workspace/` layout
|
|
2826
|
+
* (docs/design/2026-09-23-simplified-workspace-model.md §4): writes
|
|
2827
|
+
* `<dir>/oats-local.yaml` naming the workspace, creates `<dir>/agents/` (the
|
|
2828
|
+
* instance homes), then runs exactly the `oats sync` path — discover over the
|
|
2829
|
+
* remotes, confirm membership, resolve `packages:`, approve (TTY) or list what
|
|
2830
|
+
* needs approval (exit 2), write `oats-lock.json`. Nothing is installed, no soul
|
|
2831
|
+
* is created, nothing is spawned, no `oats-config.yaml` is written: the member
|
|
2832
|
+
* clones and the setup expert are the operator's next steps, printed here. */
|
|
2833
|
+
async function onboardCmd() {
|
|
2834
|
+
const bail = (code, message, details) => (JSON_MODE ? jsonFail(code, message, details) : die(message));
|
|
2835
|
+
const usage = "usage: oats onboard [<dir>] --workspace <repo ref> [--json] (or --dir <dir>)";
|
|
2836
|
+
let positional, workspaceRef, dirValue;
|
|
4610
2837
|
for (let i = 1; i < args.length; i++) {
|
|
4611
2838
|
const arg = args[i];
|
|
4612
|
-
if (
|
|
4613
|
-
if (
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
2839
|
+
if (arg === "--json") continue;
|
|
2840
|
+
if (arg === "--dir" || arg === "--workspace") {
|
|
2841
|
+
const value = args[i + 1];
|
|
2842
|
+
if (value === undefined || value.startsWith("--")) return bail("E_BAD_ARGS", `--${arg.slice(2)} needs a value\n${usage}`);
|
|
2843
|
+
if (arg === "--dir") { if (dirValue !== undefined) return bail("E_BAD_ARGS", usage); dirValue = value; }
|
|
2844
|
+
else { if (workspaceRef !== undefined) return bail("E_BAD_ARGS", usage); workspaceRef = value; }
|
|
2845
|
+
i++; continue;
|
|
2846
|
+
}
|
|
2847
|
+
if (arg.startsWith("--")) return bail("E_BAD_ARGS", `unknown flag ${arg}\n${usage}`);
|
|
2848
|
+
if (positional !== undefined) return bail("E_BAD_ARGS", usage);
|
|
2849
|
+
positional = arg;
|
|
2850
|
+
}
|
|
2851
|
+
if (positional !== undefined && dirValue !== undefined) return bail("E_BAD_ARGS", `give the deployment directory once, as <dir> or --dir\n${usage}`);
|
|
2852
|
+
if (!workspaceRef || !workspaceRef.trim()) return bail("E_BAD_ARGS", `--workspace <repo ref> is required (the repository hosting oats-workspace.yaml)\n${usage}`);
|
|
2853
|
+
workspaceRef = workspaceRef.trim();
|
|
2854
|
+
// The ref must be one lib/remote.mjs understands BEFORE anything is written.
|
|
2855
|
+
try { remoteModule.parseRepoRef(workspaceRef); }
|
|
2856
|
+
catch (e) { return bail(e.code || "E_REPO_REF", e.message, e.details ?? e.provenance); }
|
|
2857
|
+
|
|
2858
|
+
const dir = resolve(positional ?? dirValue ?? process.cwd());
|
|
2859
|
+
const localFile = join(dir, "oats-local.yaml");
|
|
2860
|
+
// (1) Refuse to onboard twice: THIS directory's oats-local.yaml is the mark (an enclosing
|
|
2861
|
+
// deployment's file does not count — a nested directory is a different deployment).
|
|
2862
|
+
let existing = null;
|
|
2863
|
+
try { existing = lstatSync(localFile); } catch (e) { if (e.code !== "ENOENT") return bail("E_ONBOARD_FAILED", `cannot inspect ${localFile}: ${e.message}`); }
|
|
2864
|
+
if (existing) return bail("E_ALREADY_ONBOARDED", `${shortPath(localFile)} already exists — this directory realizes a workspace; run \`oats sync --dir ${shortPath(dir)}\` to refresh it`, { local: localFile, dir });
|
|
2865
|
+
if (existsSync(dir) && !lstatSync(dir).isDirectory()) return bail("E_ONBOARD_FAILED", `${shortPath(dir)} exists and is not a directory`, { dir });
|
|
2866
|
+
|
|
2867
|
+
// (2) The two files/dirs onboarding owns. Written atomically; rolled back if the sync
|
|
2868
|
+
// that follows cannot even read the workspace (a typo'd ref must not leave a
|
|
2869
|
+
// half-onboarded directory that looks finished).
|
|
2870
|
+
const created = [];
|
|
4620
2871
|
try {
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
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 */ }
|
|
2872
|
+
if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); created.push(dir); }
|
|
2873
|
+
const local = { schemaVersion: 2, workspace: workspaceRef };
|
|
2874
|
+
writeFileAtomic(localFile, YAML.stringify(local));
|
|
2875
|
+
created.push(localFile);
|
|
2876
|
+
const agentsDir = join(dir, "agents");
|
|
2877
|
+
if (!existsSync(agentsDir)) { mkdirSync(agentsDir); created.push(agentsDir); }
|
|
2878
|
+
} catch (e) {
|
|
2879
|
+
rollback();
|
|
2880
|
+
return bail(e.code && String(e.code).startsWith("E_") ? e.code : "E_ONBOARD_FAILED", `cannot write ${shortPath(dir)}: ${e.message}`, { dir });
|
|
2881
|
+
}
|
|
2882
|
+
/** Only what THIS onboard created, only while still exactly ours: our local file, an EMPTY
|
|
2883
|
+
* agents/, an otherwise-empty <dir> (rmdirSync refuses a non-empty directory — evidence stays). */
|
|
2884
|
+
function rollback() {
|
|
2885
|
+
for (const p of [...created].reverse()) {
|
|
2886
|
+
try { if (p === localFile) rmSync(p, { force: true }); else rmdirSync(p); }
|
|
2887
|
+
catch { /* leave evidence rather than erase another writer's work */ }
|
|
4741
2888
|
}
|
|
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
2889
|
}
|
|
2890
|
+
const bailRollback = (code, message, details) => { rollback(); return bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, rolledBack: true }); };
|
|
2891
|
+
|
|
2892
|
+
// (3) Exactly the `oats sync` body over the deployment just written. A failure while
|
|
2893
|
+
// DISCOVERING (unreadable remote, not a workspace host) rolls the two files back; once the
|
|
2894
|
+
// workspace has been read, the files stay (a lock may already be written).
|
|
2895
|
+
let ctx;
|
|
2896
|
+
try {
|
|
2897
|
+
const found = loadLocal(dir);
|
|
2898
|
+
ctx = { dir, localPath: found.path, local: found.local, deploymentDir: dirname(found.path), remoteOptions: remoteOptionsFromEnv() };
|
|
2899
|
+
} catch (e) { return bailRollback(e.code || "E_LOCAL_MISSING", e.message, e.details); }
|
|
2900
|
+
let discovered = false;
|
|
2901
|
+
const syncBail = (code, message, details) => (discovered
|
|
2902
|
+
? bail(code, message, { ...(details && typeof details === "object" ? details : {}), dir, local: localFile })
|
|
2903
|
+
: bailRollback(code, message, details));
|
|
2904
|
+
const synced = await performSync(ctx, syncBail, { onDiscovered: () => { discovered = true; } });
|
|
2905
|
+
|
|
2906
|
+
// (4) The taught layout as next steps (design doc §4), and the envelope.
|
|
2907
|
+
const standalone = synced.discovery.standalone === true;
|
|
2908
|
+
const members = synced.report.members;
|
|
2909
|
+
// The setup expert is suggested only when THIS workspace lists a soul by that name (a
|
|
2910
|
+
// confirmed member's or, standalone, the repo's own); otherwise any listed soul is spawnable.
|
|
2911
|
+
const soulNames = synced.items.souls.map((s) => s.name);
|
|
2912
|
+
const setupExpert = soulNames.includes("oats-setup-expert");
|
|
2913
|
+
const spawnHint = setupExpert ? `oats spawn oats-setup-expert --dir ${shortPath(dir)}` : null;
|
|
2914
|
+
const anySoulHint = `spawn any listed soul: oats spawn <soul> --dir ${shortPath(dir)}${soulNames.length ? ` (e.g. ${soulNames.slice(0, 3).join(", ")})` : ""}`;
|
|
2915
|
+
// A member's clone goes beside oats-local.yaml under its repo name; `agents/` is the instance
|
|
2916
|
+
// homes, so a member called "agents" is cloned as `agents-repo/` (design doc §4).
|
|
2917
|
+
const cloneDirOf = (m) => join(dir, m.name === "agents" ? "agents-repo" : m.name);
|
|
2918
|
+
const clones = members.filter((m) => m.confirmed || (standalone && m.key === synced.discovery.key)).map((m) => ({ key: m.key, name: m.name, url: memberUrlOf(synced.discovery, m.key), dir: cloneDirOf(m) }));
|
|
2919
|
+
// Decision 26: the host publishes the member list to whoever can read it. When the host is
|
|
2920
|
+
// itself a member (the common `agents` shape) that is fine for an all-private or all-public
|
|
2921
|
+
// organisation; a mixed one needs a private host that is NOT a public member. The kernel
|
|
2922
|
+
// cannot see forge visibility, so it states the rule rather than judging.
|
|
2923
|
+
const hostIsMember = members.some((m) => m.key === synced.discovery.key);
|
|
2924
|
+
const hosting = { host: synced.discovery.key, hostIsMember, rule: "If any member is private, host oats-workspace.yaml in a private repo that is not a public member (a dedicated <org>/workspace repo); public contributors then use the standalone case (from: here capabilities + oats.core)." };
|
|
2925
|
+
const result = { onboardApi: 2, standalone: standalone || undefined, local: localFile, dir, agents: join(dir, "agents"), lock: synced.lockFile, sync: synced.report, hosting, next: { clone: clones, spawn: spawnHint, souls: soulNames.slice(0, 3) } };
|
|
2926
|
+
if (JSON_MODE) { jsonOk(result); process.exitCode = synced.approvalNeeded.length ? 2 : 0; return; }
|
|
2927
|
+
|
|
2928
|
+
console.log(`Onboarded ${shortPath(dir)} into workspace ${workspaceName(synced.discovery)} (${synced.discovery.key} @ ${short(synced.discovery.commit)}).${standalone ? `\n (standalone — the workspace of ${memberLabel(synced.discovery.key)} cannot be read from here; you get its own souls + oats.core)` : ""}\n`);
|
|
2929
|
+
printSyncReport(ctx, synced);
|
|
2930
|
+
console.log(`
|
|
2931
|
+
Layout (the taught convention — the kernel finds clones through oats-local.yaml, so any layout works):
|
|
2932
|
+
${shortPath(dir)}/
|
|
2933
|
+
├── oats-local.yaml which workspace this machine realizes (+ host settings, disabled souls)
|
|
2934
|
+
├── oats-lock.json exact commit + integrity + per-version executable approval per package
|
|
2935
|
+
├── agents/ instance homes, each self-contained
|
|
2936
|
+
└── <member>/ clones of the members you will work IN (only those)
|
|
2937
|
+
|
|
2938
|
+
Next:
|
|
2939
|
+
1. Clone the members you will work IN beside oats-local.yaml (discovery and resolution run over the
|
|
2940
|
+
remotes; only a soul's work target needs a clone):${clones.map((c) => `\n git clone ${c.url ?? c.key} ${shortPath(c.dir)}`).join("") || "\n (no confirmed members yet — see the membership rows above)"}
|
|
2941
|
+
A clone elsewhere is fine: point at it in oats-local.yaml under clones: { <repo key>: <abs path> }.
|
|
2942
|
+
2. Check who may read the host: ${synced.discovery.key}${hostIsMember ? " is itself a member" : " is a dedicated host"}. The workspace file
|
|
2943
|
+
names every member, so if any member is private the host must be a private repo that is not
|
|
2944
|
+
a public member; public contributors then get the standalone case (from: here + oats.core).
|
|
2945
|
+
3. ${setupExpert ? "Spawn the setup expert to guide the rest (souls, teams, provider settings, approvals):" : "No soul named oats-setup-expert is listed here —"}
|
|
2946
|
+
${spawnHint ?? anySoulHint}${synced.approvalNeeded.length ? `\n (first: \`oats sync --dir ${shortPath(dir)}\` in a terminal to approve ${synced.approvalNeeded.map((a) => `${a.id} ${a.version}`).join(", ")})` : ""}`);
|
|
2947
|
+
process.exitCode = synced.approvalNeeded.length ? 2 : 0;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/** The clone URL of a member row: what the remote observed (from the workspace's members: refs;
|
|
2951
|
+
* standalone, the one repo oats-local.yaml named). */
|
|
2952
|
+
function memberUrlOf(discovery, key) {
|
|
2953
|
+
for (const ref of discovery.workspace?.members || []) {
|
|
2954
|
+
try { const parsed = remoteModule.parseRepoRef(ref); if (parsed.key === key) return parsed.url; } catch { /* schema already validated */ }
|
|
2955
|
+
}
|
|
2956
|
+
if (discovery.standalone === true && discovery.key === key) return discovery.url ?? null;
|
|
2957
|
+
return null;
|
|
4744
2958
|
}
|
|
4745
2959
|
|
|
4746
2960
|
function createCmd() {
|
|
@@ -4773,7 +2987,7 @@ function createCmd() {
|
|
|
4773
2987
|
// step here, not only at the refusal.
|
|
4774
2988
|
const declared = r.declaredCapabilities || [];
|
|
4775
2989
|
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,
|
|
2990
|
+
const next = inactive.length ? [{ code: "next-step", message: `${name} declares ${inactive.join(", ")}; before spawning, pin their packages in oats-workspace.yaml packages: and declare them in soul.yaml capabilities: { <cap>: { from } } (workspace model v2)` }] : [];
|
|
4777
2991
|
const notes = [...(r.notes || []), ...next];
|
|
4778
2992
|
if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(notes.length ? { notes } : {}), ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
|
|
4779
2993
|
for (const information of notes) console.error(`[${information.code}] ${information.message}`);
|
|
@@ -4817,9 +3031,11 @@ function capabilityCommand() {
|
|
|
4817
3031
|
// manifests. Null-prototype because the dispatcher indexes it with the
|
|
4818
3032
|
// namespace the operator typed on the command line.
|
|
4819
3033
|
let capSettings = Object.create(null);
|
|
3034
|
+
let instanceModules = false;
|
|
4820
3035
|
try {
|
|
4821
3036
|
if (metaFile && existsSync(metaFile)) {
|
|
4822
3037
|
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
3038
|
+
instanceModules = !!(meta.modules && typeof meta.modules === "object");
|
|
4823
3039
|
activeIds = (meta.capabilities || []).map((c) => c.id);
|
|
4824
3040
|
for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
|
|
4825
3041
|
context = meta.repo || context;
|
|
@@ -4833,7 +3049,9 @@ function capabilityCommand() {
|
|
|
4833
3049
|
teamCtx = resolved.team;
|
|
4834
3050
|
}
|
|
4835
3051
|
} catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
|
|
4836
|
-
|
|
3052
|
+
// Workspace model: an instance's own materialized modules are the command
|
|
3053
|
+
// namespaces available to it (instance.json.modules → <home>/.oats/modules).
|
|
3054
|
+
const mans = Object.values(capabilityManifests(instanceModules ? instanceHome : context)).filter((m) => m.command === cmd && m.commands);
|
|
4837
3055
|
if (!mans.length) return NOT_DISPATCHED;
|
|
4838
3056
|
if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
|
|
4839
3057
|
const m = mans[0];
|
|
@@ -4935,59 +3153,6 @@ function typeCmd() {
|
|
|
4935
3153
|
console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
|
|
4936
3154
|
}
|
|
4937
3155
|
|
|
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
3156
|
// ---------- update ----------
|
|
4992
3157
|
function updateCmd() {
|
|
4993
3158
|
const checkOnly = args.includes("--check");
|
|
@@ -5038,7 +3203,10 @@ function versionCmd() {
|
|
|
5038
3203
|
// on it (an older CLI without the surface must fail closed with a
|
|
5039
3204
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
5040
3205
|
// must see before relying on them (retire-home: retire --home).
|
|
5041
|
-
|
|
3206
|
+
// Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
|
|
3207
|
+
// runs on resolve/materialize (contract §6); a feature the binary does not implement is
|
|
3208
|
+
// never listed.
|
|
3209
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
|
|
5042
3210
|
return;
|
|
5043
3211
|
}
|
|
5044
3212
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -5176,7 +3344,7 @@ async function serverRouteCmd() {
|
|
|
5176
3344
|
// The operations contract addresses an exact member context on the host,
|
|
5177
3345
|
// so its explicit --dir travels; every other routed command takes its
|
|
5178
3346
|
// scope from the registration.
|
|
5179
|
-
const explicitScopeOk = ["inspect", "operation", "
|
|
3347
|
+
const explicitScopeOk = ["inspect", "operation", "soul", "launch-config"].includes(cmd);
|
|
5180
3348
|
if (!explicitScopeOk && (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir=")))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
|
|
5181
3349
|
if (cmd === "launch-config") {
|
|
5182
3350
|
const action = args[1];
|
|
@@ -5402,6 +3570,8 @@ async function serverRouteCmd() {
|
|
|
5402
3570
|
// added lines rather than ~150 lines of pure whitespace churn, and keeps `git
|
|
5403
3571
|
// blame` pointing at the commit that last changed each command.
|
|
5404
3572
|
const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
|
|
3573
|
+
/** Removed 0.24 verbs → their v2 replacement (workspace model v2, decision 5). Checked before capability dispatch. */
|
|
3574
|
+
const REMOVED_VERBS = { install: "oats sync", restore: "oats sync", init: "oats-local.yaml + oats sync", use: "soul.yaml capabilities: { <cap>: { from } } + workspace defaults", trust: "oats sync (approval is asked once per package version)", list: "oats workspace status | oats capabilities", catalog: "oats package add <id> <version> (bare versions resolve through package-catalog.json)", remove: "oats package remove <id>", migrate: "a rebuild (no migration: docs/design/2026-09-23-workspace-module-contracts.md)", config: "oats-local.yaml (host settings) and oats-workspace.yaml (shared)", inject: "injection overrides are not part of the workspace model yet; edit the capability inject in its member repo" };
|
|
5405
3575
|
try {
|
|
5406
3576
|
// Inspect explicit selectors with the existing parser before new-work routing,
|
|
5407
3577
|
// including selectors before the command. Inherited captures are not prepare inputs.
|
|
@@ -5431,12 +3601,13 @@ if (cmd === "prepare" || captured?.args[0] === "prepare") {
|
|
|
5431
3601
|
}
|
|
5432
3602
|
if (cmd === "onboard" || captured?.args[0] === "onboard") {
|
|
5433
3603
|
if (captured) {
|
|
5434
|
-
if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit
|
|
5435
|
-
die("onboard is explicit
|
|
3604
|
+
if (JSON_MODE) jsonFail("E_BAD_ARGS", "onboard is explicit workspace bootstrap and cannot use captured selectors");
|
|
3605
|
+
die("onboard is explicit workspace bootstrap and cannot use captured selectors");
|
|
5436
3606
|
}
|
|
5437
3607
|
if (args.includes("--help") || args.includes("-h")) { if (JSON_MODE) jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); else usageFor(cmd); process.exit(0); }
|
|
5438
|
-
onboardCmd();
|
|
3608
|
+
await onboardCmd();
|
|
5439
3609
|
}
|
|
3610
|
+
else {
|
|
5440
3611
|
// Other commands retain their existing explicit/inherited selection rules.
|
|
5441
3612
|
try { captured ??= capturedSelector(args); }
|
|
5442
3613
|
catch (error) {
|
|
@@ -5455,7 +3626,7 @@ if (captured) {
|
|
|
5455
3626
|
// `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
|
|
5456
3627
|
const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
|
|
5457
3628
|
if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
|
|
5458
|
-
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "
|
|
3629
|
+
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
|
|
5459
3630
|
else if (cmd === "server") serverCmd();
|
|
5460
3631
|
else if (cmd === "inspect") inspectCmd();
|
|
5461
3632
|
else if (cmd === "operation") operationCmd();
|
|
@@ -5465,35 +3636,29 @@ else if (cmd === "doctor") {
|
|
|
5465
3636
|
const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
5466
3637
|
args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
|
|
5467
3638
|
}
|
|
5468
|
-
else if (cmd === "use") use();
|
|
5469
3639
|
else if (cmd === "update") {
|
|
3640
|
+
// `oats update <package>` left with the installed tier (packages are pinned in
|
|
3641
|
+
// the workspace file: `oats package add`); only the kernel self-update remains.
|
|
5470
3642
|
const t = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
5471
|
-
|
|
5472
|
-
|
|
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();
|
|
3643
|
+
if (t || flag("to") !== undefined) { cmdFail("E_BAD_ARGS", "oats update takes no package: pin package versions in oats-workspace.yaml (`oats package add <id> <version>`), then `oats sync`; bare `oats update [--check] [--yes]` updates the kernel"); process.exit(1); }
|
|
3644
|
+
updateCmd();
|
|
5475
3645
|
}
|
|
5476
3646
|
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
3647
|
else if (cmd === "readiness") readinessCmd();
|
|
5484
3648
|
else if (cmd === "instance") instanceCmd();
|
|
5485
|
-
else if (cmd === "remove") removeCmd();
|
|
5486
|
-
else if (cmd === "migrate") migrateCmd();
|
|
5487
3649
|
else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
|
|
5488
|
-
else if (cmd === "
|
|
5489
|
-
else if (cmd === "
|
|
3650
|
+
else if (cmd === "sync") await syncCmd();
|
|
3651
|
+
else if (cmd === "package") await packageCmd();
|
|
3652
|
+
else if (cmd === "workspace") await workspaceCmd();
|
|
3653
|
+
else if (cmd === "capabilities" || cmd === "souls") await itemsCmd(cmd);
|
|
3654
|
+
else if (cmd === "status") await status();
|
|
5490
3655
|
else if (cmd === "pane") await paneCmd();
|
|
5491
3656
|
else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
|
|
5492
3657
|
// Same rule as the inner catch: a typed CLI failure surfaces with its own code
|
|
5493
3658
|
// through the shared boundary, never re-badged as a spawn-mechanism failure.
|
|
5494
3659
|
else if (cmd === "session") await sessionCmd();
|
|
5495
3660
|
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; } }
|
|
3661
|
+
else if (cmd === "spawn") { try { await spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
|
|
5497
3662
|
else if (cmd === "retire") retireCmd();
|
|
5498
3663
|
else if (cmd === "create") createCmd();
|
|
5499
3664
|
else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
|
|
@@ -5502,14 +3667,24 @@ else if (cmd === "experimental") await experimentalCmd();
|
|
|
5502
3667
|
// word, so without this it reaches the capability dispatch, which resolves the
|
|
5503
3668
|
// config chain and reads every lock in it — and a scope whose lock the kernel
|
|
5504
3669
|
// refuses could then not print its own usage, which is exactly when you need it.
|
|
3670
|
+
// A removed 0.24 verb names its v2 replacement in BOTH modes, before any capability namespace could shadow it.
|
|
3671
|
+
else if (cmd && Object.hasOwn(REMOVED_VERBS, cmd)) {
|
|
3672
|
+
const message = `unknown command "${cmd}" — removed by the workspace model v2; use ${REMOVED_VERBS[cmd]}`;
|
|
3673
|
+
if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", message, { removed: cmd, replacement: REMOVED_VERBS[cmd] });
|
|
3674
|
+
console.error(`oats: ${message}\n`);
|
|
3675
|
+
console.log(usageText());
|
|
3676
|
+
process.exit(1);
|
|
3677
|
+
}
|
|
5505
3678
|
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
|
|
5506
3679
|
// No matching kernel command or capability namespace: in --json mode the help
|
|
5507
3680
|
// text must NOT contaminate stdout — still one envelope object, nonzero exit.
|
|
5508
3681
|
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `unknown command "${cmd}" — no kernel subcommand or active capability namespace matches`);
|
|
5509
3682
|
else {
|
|
3683
|
+
if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
|
|
5510
3684
|
console.log(usageText());
|
|
5511
3685
|
process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
|
|
5512
3686
|
}
|
|
3687
|
+
} // end: every command but onboard
|
|
5513
3688
|
|
|
5514
3689
|
/** The usage lines for one kernel command (its `oats <cmd> ...` lines and
|
|
5515
3690
|
* their indented continuations), or the whole usage when none match. */
|
|
@@ -5559,7 +3734,7 @@ Usage:
|
|
|
5559
3734
|
oats session start --server <id> start a stopped remote instance in its existing home
|
|
5560
3735
|
--instance <name> | --home <abs> over its saved route; the server must advertise
|
|
5561
3736
|
[--model <m>] [--json] session-start (oats 0.22.9 or later)
|
|
5562
|
-
oats inspect|operation|
|
|
3737
|
+
oats inspect|operation|soul --server <id> the same commands on a registered server over its
|
|
5563
3738
|
... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
|
|
5564
3739
|
is its own context; else the registered workspace);
|
|
5565
3740
|
soul set --instructions-file streams the bytes; the
|
|
@@ -5568,9 +3743,11 @@ Usage:
|
|
|
5568
3743
|
--instance <name> | --home <abs> attachments over its saved route (bytes stream on
|
|
5569
3744
|
--file <path> [--json] ssh stdin; sha256 verified); the server must
|
|
5570
3745
|
advertise session-upload (oats 0.22.13 or later)
|
|
5571
|
-
oats onboard [
|
|
5572
|
-
[--
|
|
5573
|
-
|
|
3746
|
+
oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
|
|
3747
|
+
[--json] and agents/, then runs the oats sync path (lock v3;
|
|
3748
|
+
exit 2 while approvals are pending) and prints the
|
|
3749
|
+
next steps (clone members you work IN, spawn
|
|
3750
|
+
oats-setup-expert); creates no soul, spawns nothing
|
|
5574
3751
|
oats create <name> [--local] [--no-oats-core] create an agent soul; --local = full
|
|
5575
3752
|
[--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
|
|
5576
3753
|
[--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
|
|
@@ -5654,28 +3831,21 @@ Usage:
|
|
|
5654
3831
|
--soul shows final composed AGENTS.md
|
|
5655
3832
|
oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
|
|
5656
3833
|
optionally run the update; then run oats doctor
|
|
5657
|
-
oats
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
[--
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
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)
|
|
3834
|
+
oats sync [--dir <d>] [--json] workspace model v2: observe the workspace named by
|
|
3835
|
+
oats-local.yaml over its Git remote, confirm every
|
|
3836
|
+
member (reciprocal oats-membership.yaml), resolve
|
|
3837
|
+
packages: to exact commits, ask executable approval
|
|
3838
|
+
once per package version (TTY; otherwise list what
|
|
3839
|
+
needs it and exit 2), write oats-lock.json (v3) and
|
|
3840
|
+
report the diff
|
|
3841
|
+
oats package add <id> <version|git:<repo>@<ref>> edit packages: in oats-workspace.yaml when the
|
|
3842
|
+
| remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
|
|
3843
|
+
print the line to add (the file travels through Git)
|
|
3844
|
+
oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
|
|
3845
|
+
cannot-read / backlink-elsewhere), packages, approval
|
|
3846
|
+
oats capabilities [--dir <d>] [--json] every non-private capability of every confirmed
|
|
3847
|
+
oats souls [--dir <d>] [--json] member + the locked packages, with origin
|
|
3848
|
+
(member <key> @ <commit> | package <id> v<ver>) and team
|
|
5679
3849
|
oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
|
|
5680
3850
|
read-only Git observation of the instance's work
|
|
5681
3851
|
tree: branch, status (renames kept), ahead/behind
|
|
@@ -5713,68 +3883,8 @@ Usage:
|
|
|
5713
3883
|
<workspace>/.agents/worktrees/<repo>/<branch>)
|
|
5714
3884
|
unless discarded; --delete-branch deletes the
|
|
5715
3885
|
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
3886
|
oats type add <name> [--description <d>] declare an agent type (family) in config;
|
|
5760
3887
|
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
3888
|
oats root print this package's install root
|
|
5779
3889
|
(adapters resolve the kernel from it)
|
|
5780
3890
|
|
|
@@ -5832,7 +3942,7 @@ The turn record (core — every conversation captured, searchable, replicated):
|
|
|
5832
3942
|
oats <namespace> <command> [args…] run an operational command only when its
|
|
5833
3943
|
capability is active (e.g. oats okf harvest)
|
|
5834
3944
|
|
|
5835
|
-
Layers: ${LAYERS.join(", ")}.
|
|
3945
|
+
Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
|
|
5836
3946
|
}
|
|
5837
3947
|
} catch (e) {
|
|
5838
3948
|
if (!TYPED_CLI_FAILURES.has(e?.code)) throw e;
|