@awebai/oats 0.22.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/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/oats.mjs +3294 -0
- package/capabilities/oats-authoring/oats.json +12 -0
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
- package/capabilities/oats-aweb/injects/aweb.md +55 -0
- package/capabilities/oats-aweb/oats.json +47 -0
- package/capabilities/oats-aweb/package.json +20 -0
- package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
- package/capabilities/oats-jira/injects/jira.md +10 -0
- package/capabilities/oats-jira/oats.json +22 -0
- package/capabilities/oats-jira/package.json +25 -0
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
- package/capabilities/oats-linear/README.md +234 -0
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
- package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
- package/capabilities/oats-linear/injects/linear.md +8 -0
- package/capabilities/oats-linear/oats.json +24 -0
- package/capabilities/oats-linear/package.json +29 -0
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
- package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
- package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
- package/capabilities/oats-okf/injects/okf.md +62 -0
- package/capabilities/oats-okf/oats.json +20 -0
- package/capabilities/oats-okf/package.json +22 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
- package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
- package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
- package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
- package/capabilities/oats-review/injects/review.md +68 -0
- package/capabilities/oats-review/oats.json +10 -0
- package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
- package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
- package/docs/capabilities.md +505 -0
- package/docs/capability-manifest.schema.json +223 -0
- package/docs/configuration.md +482 -0
- package/docs/conventions.md +73 -0
- package/docs/design/desktop-ux-plan.md +362 -0
- package/docs/design/package-engine-contract.md +813 -0
- package/docs/design/package-runtime-api.md +414 -0
- package/docs/desktop-cli-api.md +89 -0
- package/docs/desktop-succession.md +51 -0
- package/docs/desktop.md +187 -0
- package/docs/implementation.md +282 -0
- package/docs/integrations.md +123 -0
- package/docs/knowledge-theory.md +97 -0
- package/docs/knowledge.md +139 -0
- package/docs/layers.md +110 -0
- package/docs/migration-from-oas.md +122 -0
- package/docs/oats-config.schema.json +140 -0
- package/docs/oats-lock.schema.json +175 -0
- package/docs/oats-package.schema.json +129 -0
- package/docs/packages.md +442 -0
- package/docs/release-lane.md +132 -0
- package/docs/release-notes/v0.18.2.md +79 -0
- package/docs/release-notes/v0.18.3.md +63 -0
- package/docs/release-notes/v0.18.4.md +65 -0
- package/docs/release-notes/v0.18.5.md +66 -0
- package/docs/release-notes/v0.18.6.md +87 -0
- package/docs/release-notes/v0.19.0.md +186 -0
- package/docs/release-notes/v0.19.1.md +24 -0
- package/docs/release-notes/v0.19.2.md +32 -0
- package/docs/release-notes/v0.19.3.md +16 -0
- package/docs/release-notes/v0.19.4.md +12 -0
- package/docs/release-notes/v0.20.0.md +89 -0
- package/docs/release-notes/v0.22.0.md +180 -0
- package/docs/souls-and-instances.md +344 -0
- package/injects/framework-workspace.md +7 -0
- package/injects/instance-boundary.md +43 -0
- package/injects/local-soul.md +19 -0
- package/injects/oats.md +11 -0
- package/injects/work-attached.md +16 -0
- package/injects/work-checkout.md +12 -0
- package/injects/work-workspace.md +22 -0
- package/injects/work-worktree.md +13 -0
- package/lib/core.mjs +6792 -0
- package/lib/packages.mjs +1375 -0
- package/lib/tmux-config.mjs +35 -0
- package/package-catalog.json +44 -0
- package/package.json +55 -0
- package/packages/record/README.md +151 -0
- package/packages/record/bin/capture.mjs +256 -0
- package/packages/record/bin/recall.mjs +102 -0
- package/packages/record/bin/setup.mjs +281 -0
- package/packages/record/bin/turn-record.mjs +47 -0
- package/packages/record/docs/turn-record-sot.md +350 -0
- package/packages/record/lib/canonical.mjs +151 -0
- package/packages/record/lib/capture-aw.mjs +179 -0
- package/packages/record/lib/capture-cc.mjs +322 -0
- package/packages/record/lib/formats.mjs +296 -0
- package/packages/record/lib/ignore.mjs +155 -0
- package/packages/record/lib/index-db.mjs +524 -0
- package/packages/record/lib/project-aweb.mjs +251 -0
- package/packages/record/lib/segments.mjs +186 -0
- package/packages/record/lib/store.mjs +447 -0
- package/packages/record/lib/tags.mjs +114 -0
- package/packages/record/package.json +45 -0
- package/packages/record/test/vectors/README.md +34 -0
- package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
- package/packages/record/test/vectors/journal-merge-v1.json +261 -0
- package/packages/record/test/vectors/turn-id-v1.json +152 -0
- package/packages/record/test/vectors/validate.mjs +391 -0
- package/skills/integration-authoring/SKILL.md +76 -0
- package/skills/oats/SKILL.md +151 -0
- package/skills/oats-config/SKILL.md +162 -0
- package/skills/oats-getting-started/SKILL.md +159 -0
- package/skills/oats-packages/SKILL.md +180 -0
- package/skills/oats-support/SKILL.md +79 -0
- package/skills/skill-craft/SKILL.md +109 -0
- package/skills/soul-craft/SKILL.md +109 -0
package/bin/oats.mjs
ADDED
|
@@ -0,0 +1,3294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* oats — the OATS command line.
|
|
4
|
+
*
|
|
5
|
+
* oats doctor [dir] [--json] show the resolved config with origins
|
|
6
|
+
* oats install <name|url|path> [...] acquire + exact-lock a capability
|
|
7
|
+
* oats trust <capability> approve locked executable surfaces
|
|
8
|
+
* oats use <capability> [...] activate/exclude for global/group/soul
|
|
9
|
+
* oats init [--raw] create an oats-config.yaml here
|
|
10
|
+
*
|
|
11
|
+
* `use` and `init` edit the oats-config.yaml at the detected level root:
|
|
12
|
+
* cwd is your home dir → laptop; cwd has .git → repo; otherwise → workspace.
|
|
13
|
+
* The kernel resolves per-key closest-wins from wherever agents actually run,
|
|
14
|
+
* so binding at a level scopes the capability to everything under it.
|
|
15
|
+
*/
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { enableTmuxMouse, tmuxConfigPath, tmuxMouseEnabled } from "../lib/tmux-config.mjs";
|
|
22
|
+
import {
|
|
23
|
+
LAYERS, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain,
|
|
24
|
+
acquireCapability, restoreCapabilities, marketplaceCapabilities,
|
|
25
|
+
capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath,
|
|
26
|
+
readCapabilityLocks, writeCapabilityLock,
|
|
27
|
+
parsePackageSource, inspectGitSourceRoot, acquirePackage, restorePackages, listInstalledPackages, readPackageLocks, readLockedConfigTemplates,
|
|
28
|
+
officialCapabilityPackage, officialPackageCatalog,
|
|
29
|
+
approveCapability, updatePackage, removePackage, migrateLegacyLock, applyLegacyLockMigration,
|
|
30
|
+
packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir, installedCapabilitiesDir, ownedCapabilitiesDir, loadPackageManifestAt,
|
|
31
|
+
resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
|
|
32
|
+
findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
|
|
33
|
+
ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
|
|
34
|
+
spawnInstance, retireInstance, upsertLocalAgent, defaultRepo, RELATIONS,
|
|
35
|
+
} from "../lib/core.mjs";
|
|
36
|
+
import {
|
|
37
|
+
aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
|
|
38
|
+
adoptedTemplateDir, applyConfigMerge, lockedPackageCapabilities, planConfigMerge, readAdoptedTemplate, requirementInstallPlan,
|
|
39
|
+
assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
|
|
40
|
+
runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
|
|
41
|
+
} from "../lib/packages.mjs";
|
|
42
|
+
|
|
43
|
+
const args = process.argv.slice(2);
|
|
44
|
+
const cmd = args[0];
|
|
45
|
+
const HELP_WORDS = new Set(["help", "--help", "-h"]);
|
|
46
|
+
const flag = (name) => {
|
|
47
|
+
const i = args.indexOf(`--${name}`);
|
|
48
|
+
return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
|
|
49
|
+
};
|
|
50
|
+
const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
|
|
51
|
+
/** Resolve the --dir flag with central validation: a value-taking flag given
|
|
52
|
+
* no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
|
|
53
|
+
* uncaught resolve(true) TypeError (reviewer-6f0a3bd). */
|
|
54
|
+
function dirFlag() {
|
|
55
|
+
const v = flag("dir");
|
|
56
|
+
if (v === undefined) return resolve(process.cwd());
|
|
57
|
+
if (v === true || !String(v).trim()) {
|
|
58
|
+
const msg = "--dir needs a directory path";
|
|
59
|
+
if (JSON_MODE) { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: "E_BAD_ARGS", message: msg } })); process.exit(1); }
|
|
60
|
+
die(msg);
|
|
61
|
+
}
|
|
62
|
+
return resolve(String(v));
|
|
63
|
+
}
|
|
64
|
+
// Desktop CLI API v1 (JSON mode): every `--json` failure is EXACTLY ONE JSON
|
|
65
|
+
// object on stdout — { schemaVersion: 1, ok: false, error: { code, message } } —
|
|
66
|
+
// with a nonzero exit; progress prose goes to stderr, never stdout.
|
|
67
|
+
const JSON_MODE = args.includes("--json");
|
|
68
|
+
// Canonical absolute path of this CLI executable — the versioned OATS_CLI_BIN
|
|
69
|
+
// env contract for dispatched package commands (never resolved via PATH).
|
|
70
|
+
const CLI_BIN = realpathSync(fileURLToPath(import.meta.url));
|
|
71
|
+
const jsonFail = (code, message) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message) } })); process.exit(1); };
|
|
72
|
+
const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result })); };
|
|
73
|
+
|
|
74
|
+
/** Level of a directory: laptop (home), repo (.git), else workspace. */
|
|
75
|
+
function levelOf(dir) {
|
|
76
|
+
const d = resolve(dir);
|
|
77
|
+
if (d === homedir()) return "laptop";
|
|
78
|
+
if (existsSync(join(d, ".git"))) return "repo";
|
|
79
|
+
return "workspace";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shortPath(p) {
|
|
83
|
+
if (!p) return p;
|
|
84
|
+
const home = homedir();
|
|
85
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Shell-safe single-quoting for copyable human commands (paths may contain spaces/metacharacters). */
|
|
89
|
+
function shellQuote(s) {
|
|
90
|
+
return /^[A-Za-z0-9._/~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The scaffolded `name:` value — the target directory's basename — held to the
|
|
94
|
+
* SAME write refusal as every other value this CLI renders into a config line.
|
|
95
|
+
*
|
|
96
|
+
* A basename is filesystem input, not a literal: a directory whose name embeds
|
|
97
|
+
* a newline turned one scaffolded `name:` line into arbitrary top-level config
|
|
98
|
+
* blocks (a live `team:` block smuggled through `oats init`), and a `#`-leading
|
|
99
|
+
* basename wrote a value that reads back as an empty map. Refusing names the
|
|
100
|
+
* offending basename and writes nothing — the operator renames the directory. */
|
|
101
|
+
function scaffoldConfigName(dir) {
|
|
102
|
+
return assertSafeConfigValue(basename(dir), `the scaffolded name from the directory basename ${JSON.stringify(basename(dir))}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function offerTmuxMouseScrolling() {
|
|
106
|
+
if (args.includes("--no-tmux-mouse")) return;
|
|
107
|
+
const configPath = tmuxConfigPath();
|
|
108
|
+
const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
109
|
+
if (tmuxMouseEnabled(current)) return;
|
|
110
|
+
|
|
111
|
+
let accepted = args.includes("--tmux-mouse");
|
|
112
|
+
if (!accepted) {
|
|
113
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
114
|
+
process.stdout.write("Enable normal mouse/trackpad scrolling in tmux agent windows? [Y/n] ");
|
|
115
|
+
const buffer = Buffer.alloc(256);
|
|
116
|
+
const length = readSync(process.stdin.fd, buffer, 0, buffer.length);
|
|
117
|
+
accepted = !buffer.subarray(0, length).toString("utf8").trim().toLowerCase().startsWith("n");
|
|
118
|
+
}
|
|
119
|
+
if (!accepted) return;
|
|
120
|
+
|
|
121
|
+
const result = enableTmuxMouse(configPath);
|
|
122
|
+
console.log(`Enabled tmux mouse scrolling in ${shortPath(result.configPath)}${result.reloaded ? " (reloaded)" : ""}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- doctor ----------
|
|
126
|
+
/** Doctor must diagnose, not crash: a stale activation of a retired
|
|
127
|
+
* capability fails config resolution — surface the cleanup instruction
|
|
128
|
+
* cleanly (text or JSON) instead of an uncaught stack trace. */
|
|
129
|
+
function resolveForDoctor(ctx, soulName, { json } = {}) {
|
|
130
|
+
try { return resolveOatsConfig(ctx, soulName); }
|
|
131
|
+
catch (e) {
|
|
132
|
+
// Doctor is THE diagnosis surface: it alone catches the typed fail-closed
|
|
133
|
+
// invalid-lock error and continues to render actionable state.
|
|
134
|
+
if (e.code === "invalid-lock") {
|
|
135
|
+
const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
|
|
136
|
+
if (json) { console.log(JSON.stringify({ context: ctx, error: { code: "invalid-lock", message: e.message, provenance: e.provenance || null } }, null, 2)); process.exit(1); }
|
|
137
|
+
console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
|
|
138
|
+
console.log(`ERROR: ${e.message} [invalid-lock]`);
|
|
139
|
+
if (prov?.file) console.log(` fix or remove the offending entry in ${shortPath(prov.file)} — the lock is never auto-repaired; all package operations fail closed until it is valid`);
|
|
140
|
+
process.exit(0); // doctor DIAGNOSED successfully; the lock is the problem
|
|
141
|
+
}
|
|
142
|
+
const retiredId = Object.keys(RETIRED_CAPABILITIES).find((id) => String(e.message).includes(`"${id}"`) && String(e.message).includes("retired"));
|
|
143
|
+
if (!retiredId) throw e;
|
|
144
|
+
if (json) { console.log(JSON.stringify({ schemaVersion: 1, context: ctx, error: e.message, retired: [retiredId] }, null, 2)); process.exit(1); }
|
|
145
|
+
die(`${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function doctorComposition(ctx, soulName) {
|
|
149
|
+
if (!soulName) return undefined;
|
|
150
|
+
const root = findRoot(ctx);
|
|
151
|
+
const agent = root && findAgent(root, soulName);
|
|
152
|
+
if (!agent) throw new Error(`unknown soul "${soulName}" for doctor composition`);
|
|
153
|
+
return composeInstanceAgentsMd(join(agent._dir, "soul"), ctx, agent.name, agent.work || "checkout", agent.kind);
|
|
154
|
+
}
|
|
155
|
+
/** WS2 package-layer doctor data — the ONE source for both human and --json
|
|
156
|
+
* doctor output: lock v2 packages, adopted-profile provenance, available-but-
|
|
157
|
+
* unapplied profiles, and missing host requirements with structured plans. */
|
|
158
|
+
/** Guided-upgrade readiness for the legacy official capabilities visible from a
|
|
159
|
+
* scope (release contract §4): which legacy `marketplace:` locks exist, which
|
|
160
|
+
* official package supplies each one, and whether this release's catalog can
|
|
161
|
+
* map them all yet. `null` when there is no legacy official state at all. */
|
|
162
|
+
function officialMigrationState(legacyLocks, { teamScope, ctx }) {
|
|
163
|
+
const capabilities = [];
|
|
164
|
+
for (const l of legacyLocks) {
|
|
165
|
+
for (const [id, entry] of Object.entries(l.capabilities || {})) {
|
|
166
|
+
if (typeof entry?.source !== "string" || !entry.source.startsWith("marketplace:")) continue;
|
|
167
|
+
let m;
|
|
168
|
+
try { m = officialCapabilityPackage(id); }
|
|
169
|
+
catch (e) { return { status: "unavailable", capabilities: [], command: null, reason: `the official package catalog is unreadable: ${e.message}` }; }
|
|
170
|
+
capabilities.push({ capability: id, package: m.package, via: m.via, available: m.available, file: l.file, level: l.level });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!capabilities.length) return null;
|
|
174
|
+
const boundary = teamScope || ctx;
|
|
175
|
+
const command = `oats migrate --official --recursive --dir ${shellQuote(boundary)}`;
|
|
176
|
+
const missing = capabilities.filter((c) => !c.available);
|
|
177
|
+
return missing.length
|
|
178
|
+
? { 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` }
|
|
179
|
+
: { status: "ready", capabilities, command, reason: null };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The health of ONE materialized capability, against the rows it was projected
|
|
183
|
+
* from. Shared by doctor and list so both name the same states with the same
|
|
184
|
+
* codes — and so the `.oats-installation.json` provenance is checked in BOTH,
|
|
185
|
+
* not only deep inside trust resolution where it surfaces as a bare "untrusted".
|
|
186
|
+
*
|
|
187
|
+
* Order matters: a missing artifact cannot be hashed, drifted bytes make an
|
|
188
|
+
* approval meaningless (so trust is not ALSO reported), and provenance is only
|
|
189
|
+
* worth reading once the bytes are the locked ones. */
|
|
190
|
+
/** The lock rows AT one level. Never the merged maps: those resolve each
|
|
191
|
+
* identity independently, so an outer scope's capability can be paired with a
|
|
192
|
+
* nearer scope's package of the same id — a provider that never exported it. */
|
|
193
|
+
const levelRows = (locks, level) => locks.levels.find((l) => l.level === level) || { packages: Object.create(null), capabilities: Object.create(null) };
|
|
194
|
+
|
|
195
|
+
function capabilityHealth(level, cap, capRow, pkgRow) {
|
|
196
|
+
const dir = installedCapabilityDir(level, cap.id);
|
|
197
|
+
if (!cap.installed) return { status: "missing", code: "missing-capability-artifact", dir, detail: `capability ${cap.id} is locked but not materialized — run \`oats install\` to re-materialize it` };
|
|
198
|
+
let integrity;
|
|
199
|
+
try { integrity = capabilityArtifactIntegrity(dir); }
|
|
200
|
+
catch (e) { return { status: "broken", code: e.code || "invalid-capability-artifact", dir, detail: `capability ${cap.id}: ${e.message}` }; }
|
|
201
|
+
if (integrity !== cap.integrity) {
|
|
202
|
+
return { status: "drifted", code: "integrity-drift", dir, integrity, detail: `capability ${cap.id}: artifact integrity drift — installed ${integrity}, locked ${cap.integrity}; its executable approval is invalid` };
|
|
203
|
+
}
|
|
204
|
+
// The artifact's own provenance and the lock must tell the SAME story before
|
|
205
|
+
// either is believed. Neither silently wins; the disagreement is the finding.
|
|
206
|
+
if (capRow && pkgRow) {
|
|
207
|
+
try { verifyCapabilityInstallation(dir, cap.id, capRow, pkgRow); }
|
|
208
|
+
catch (e) { return { status: "provenance-mismatch", code: e.code || "invalid-lock", dir, integrity, detail: `capability ${cap.id}: ${e.message}` }; }
|
|
209
|
+
}
|
|
210
|
+
const executable = Object.keys(cap.manifest?.commands || {}).length
|
|
211
|
+
|| Object.keys(cap.manifest?.hooks || {}).length
|
|
212
|
+
|| (cap.manifest?.environment?.length || 0);
|
|
213
|
+
if (executable && !cap.trusted) return { status: "untrusted", code: "untrusted-surface", dir, integrity, detail: `capability ${cap.id}: executable surface UNTRUSTED — \`oats trust ${cap.id}\`` };
|
|
214
|
+
return { status: "ok", code: null, dir, integrity, detail: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function doctorPackagesData(ctx, chain, { teamScope } = {}) {
|
|
218
|
+
// reviewer-455ba15 fix 4: the ENGINE diagnostics the human doctor renders
|
|
219
|
+
// (invalid locks, missing artifacts, integrity/runtime-closure drift,
|
|
220
|
+
// capability-list mismatches, untrusted surfaces, legacy-lock states)
|
|
221
|
+
// are computed HERE so doctor --json exposes them structurally — machine
|
|
222
|
+
// consumers see every state the human report calls broken. Fail-closed
|
|
223
|
+
// reads are diagnosed, never consumed as data and never swallowed.
|
|
224
|
+
let pkgLocks = { packages: {}, legacy: [] };
|
|
225
|
+
let installedPkgs = [];
|
|
226
|
+
let lockBroken = null;
|
|
227
|
+
try { pkgLocks = readPackageLocks(ctx); installedPkgs = listInstalledPackages(ctx); }
|
|
228
|
+
catch (e) {
|
|
229
|
+
const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
|
|
230
|
+
lockBroken = { code: e.code || "invalid-lock", message: String(e.message || e), file: prov?.file || null, provenance: e.provenance || null };
|
|
231
|
+
}
|
|
232
|
+
const packages = [];
|
|
233
|
+
for (const p of installedPkgs) {
|
|
234
|
+
// SCOPE-EXACT, like `oats list` and `oats trust` above: `p` was derived from
|
|
235
|
+
// the lock AT `p.level`, and its artifacts live under that scope, so only
|
|
236
|
+
// that scope's rows can judge them. The MERGED maps resolve each identity
|
|
237
|
+
// closest-scope-first — right for "which capability is active here", wrong
|
|
238
|
+
// here — so a chain holding one package id at two scopes (a direct
|
|
239
|
+
// acquisition outside, the same id pulled in by a dependency closure
|
|
240
|
+
// inside, each with its own source spelling) would compare an outer
|
|
241
|
+
// artifact's provenance against the inner row it was never projected from
|
|
242
|
+
// and report a self-consistent pair as invalid-lock.
|
|
243
|
+
const rows = levelRows(pkgLocks, p.level);
|
|
244
|
+
const lock = Object.hasOwn(rows.packages, p.package) ? { ...rows.packages[p.package], _file: join(p.level, OATS_LOCK_FILE), _level: p.level } : undefined;
|
|
245
|
+
const problems = [];
|
|
246
|
+
if (!lock) problems.push({ code: "invalid-lock", detail: "installed but not locked — reacquire it" });
|
|
247
|
+
else {
|
|
248
|
+
// There is no persistent package root to hash: the package row exact-locks
|
|
249
|
+
// a remote payload, and the only bytes on disk are the flat capability
|
|
250
|
+
// artifacts. So every health check is per capability, against the artifact
|
|
251
|
+
// integrity the engine recorded for it.
|
|
252
|
+
for (const c of p.capabilities) {
|
|
253
|
+
const h = capabilityHealth(p.level, c, Object.hasOwn(rows.capabilities, c.id) ? rows.capabilities[c.id] : undefined, lock);
|
|
254
|
+
if (h.status !== "ok") problems.push({ code: h.code, detail: h.detail });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
packages.push({
|
|
258
|
+
id: p.package, version: p.version || null, level: p.level, source: lock?.source || null,
|
|
259
|
+
path: lock?.path || null, commit: lock?.commit || null, capabilities: p.capabilities.map((c) => c.id),
|
|
260
|
+
dependencies: lock?.dependencies || [],
|
|
261
|
+
status: problems.length ? "broken" : "ok", problems,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
for (const [id, lock] of Object.entries(pkgLocks.packages)) {
|
|
265
|
+
if (!installedPkgs.some((p) => p.package === id)) {
|
|
266
|
+
// Capability rows carry the provider back-reference — the package row has
|
|
267
|
+
// no capability list to read any more.
|
|
268
|
+
const provided = Object.entries(pkgLocks.capabilities).filter(([, c]) => c.package === id).map(([capId]) => capId);
|
|
269
|
+
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` }] });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Supported v1 scopes — empty or not — are pending an explicit LOCK-FORMAT
|
|
273
|
+
// migration (maintainer ruling). There is no second view beside this one:
|
|
274
|
+
// migration never produces residue, and the superseded transitional v2 shape
|
|
275
|
+
// is rejected wholesale by the strict reader, so it reaches doctor as the
|
|
276
|
+
// single `lockError` diagnosis above rather than as partially parsed entries.
|
|
277
|
+
const legacyLockFiles = pkgLocks.legacy
|
|
278
|
+
.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}` }));
|
|
279
|
+
// Adoption provenance now comes from the visible, commit-safe adopted base —
|
|
280
|
+
// not from a provenance comment the local config could lose to an edit.
|
|
281
|
+
const adoptedTemplates = [];
|
|
282
|
+
for (const cfg of chain) {
|
|
283
|
+
const level = dirname(cfg._file);
|
|
284
|
+
let adopted;
|
|
285
|
+
try { adopted = readAdoptedTemplate(level); }
|
|
286
|
+
catch (e) { adoptedTemplates.push({ level, file: cfg._file, status: "broken", code: e.code || "E_ADOPTION_INVALID", detail: e.message }); continue; }
|
|
287
|
+
if (!adopted) continue;
|
|
288
|
+
let localChanges = null;
|
|
289
|
+
try { localChanges = readFileSync(cfg._file, "utf8") !== adopted.baseText; } catch { /* unreadable config is reported elsewhere */ }
|
|
290
|
+
adoptedTemplates.push({
|
|
291
|
+
level, file: cfg._file, package: adopted.package, template: adopted.template,
|
|
292
|
+
base: adopted.baseFile, source: adopted.metadata?.source || null,
|
|
293
|
+
version: adopted.metadata?.version || null, commit: adopted.metadata?.commit || null,
|
|
294
|
+
hash: adopted.metadata?.hash || null, localChanges, status: "ok",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// NOTE: doctor deliberately does NOT enumerate templates a package exports but
|
|
298
|
+
// nobody adopted. In the materialized model there is no package root on disk,
|
|
299
|
+
// so that list only exists behind a network fetch of the locked source — and
|
|
300
|
+
// a diagnostic command must never go to the network to render a hint.
|
|
301
|
+
const missingHostRequirements = aggregateMissingRequirements([ctx]).map((req) => ({
|
|
302
|
+
command: req.command, why: req.why || null, docs: req.docs || null,
|
|
303
|
+
requestedBy: req.requestedBy,
|
|
304
|
+
plan: req.plan && !req.plan.unavailable
|
|
305
|
+
? { 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 }
|
|
306
|
+
: null,
|
|
307
|
+
invalid: req.invalid || null,
|
|
308
|
+
conflict: req.conflict || null,
|
|
309
|
+
unavailable: req.plan?.unavailable || null,
|
|
310
|
+
// Context-complete + shell-safe: the copyable command pins the resolved
|
|
311
|
+
// scope with --dir so it cannot target another deployment from a
|
|
312
|
+
// different cwd. Command and ctx are validated/quoted for safe copying.
|
|
313
|
+
consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
|
|
314
|
+
? `oats install --accept-requirement ${req.command} --dir ${shellQuote(ctx)}`
|
|
315
|
+
: null,
|
|
316
|
+
}));
|
|
317
|
+
return { lockError: lockBroken, packages, legacyLockFiles, adoptedTemplates, missingHostRequirements, officialMigration: officialMigrationState(pkgLocks.legacy, { teamScope, ctx }) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function doctorJson(dir) {
|
|
321
|
+
const ctx = resolve(dir || process.cwd());
|
|
322
|
+
const soulName = flag("soul");
|
|
323
|
+
const r = resolveForDoctor(ctx, soulName, { json: true });
|
|
324
|
+
const mans = capabilityManifests(ctx);
|
|
325
|
+
const composition = doctorComposition(ctx, soulName);
|
|
326
|
+
const chain = configChain(ctx);
|
|
327
|
+
const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
|
|
328
|
+
const oasScopes = detectOasScopes(ctx);
|
|
329
|
+
console.log(JSON.stringify({
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
context: ctx,
|
|
332
|
+
team: r.team || null,
|
|
333
|
+
chain: r.chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })),
|
|
334
|
+
oasScopes,
|
|
335
|
+
oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
|
|
336
|
+
layers: Object.fromEntries(LAYERS.map((l) => [l, r.layers[l] ? {
|
|
337
|
+
integration: r.layers[l].id, level: r.layers[l].level, inject: r.layers[l].inject,
|
|
338
|
+
skills: [...(Array.isArray(r.layers[l].skills) ? r.layers[l].skills : (r.layers[l].skills ? [r.layers[l].skills] : []))],
|
|
339
|
+
hooks: Object.keys(r.layers[l].hooks || {}), missingRequires: r.layers[l].missingRequires,
|
|
340
|
+
provenance: r.provenance[l],
|
|
341
|
+
} : { provenance: r.provenance[l] || null }])),
|
|
342
|
+
kernelInjection: r.kernelInjection,
|
|
343
|
+
injects: r.injects,
|
|
344
|
+
capabilities: r.capabilities.map((c) => ({ id: c.id, layer: c.layer, command: c.command, origin: c.origin, provenance: c.provenance, settings: c.settings, skills: c.skills, inject: c.inject, hooks: Object.keys(c.hooks || {}), trust: c.trust })),
|
|
345
|
+
acquired: Object.fromEntries(Object.entries(mans).map(([n, m]) => [n, { layer: m.layer, command: m.command, version: m.version, dir: m._dir, origin: m._origin, description: m.description }])),
|
|
346
|
+
retiredLocks: (() => { try { return Object.entries(readCapabilityLocks(ctx)); } catch { return []; } })()
|
|
347
|
+
.filter(([id]) => retiredCapabilityReason(id))
|
|
348
|
+
.map(([id, lock]) => ({ id, file: lock._file, reason: retiredCapabilityReason(id) })),
|
|
349
|
+
retiredArtifacts: Object.entries(mans)
|
|
350
|
+
.filter(([id]) => retiredCapabilityReason(id))
|
|
351
|
+
.map(([id, m]) => ({ id, dir: m._dir, origin: m._origin, reason: retiredCapabilityReason(id) })),
|
|
352
|
+
// Shared WS2+engine package payload (fix 4: human and JSON doctor derive
|
|
353
|
+
// from ONE computation; fail-closed reads are diagnosed via lockError —
|
|
354
|
+
// doctorPackagesData carries the engine's legacy-lock shapes).
|
|
355
|
+
packages: pkg.packages,
|
|
356
|
+
lockError: pkg.lockError,
|
|
357
|
+
legacyLockFiles: pkg.legacyLockFiles,
|
|
358
|
+
officialMigration: pkg.officialMigration,
|
|
359
|
+
adoptedTemplates: pkg.adoptedTemplates,
|
|
360
|
+
missingHostRequirements: pkg.missingHostRequirements,
|
|
361
|
+
composedInstructions: composition?.text,
|
|
362
|
+
instructionBlocks: composition?.blocks,
|
|
363
|
+
}, null, 2));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function doctor(dir) {
|
|
367
|
+
const ctx = resolve(dir || process.cwd());
|
|
368
|
+
const soulName = flag("soul");
|
|
369
|
+
const chain = configChain(ctx);
|
|
370
|
+
const r = resolveForDoctor(ctx, soulName);
|
|
371
|
+
console.log(`oats doctor — resolved from ${shortPath(ctx)}\n`);
|
|
372
|
+
|
|
373
|
+
// Kernel/bridge version skew (published in lockstep from one tag).
|
|
374
|
+
const piPkgFile = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
|
|
375
|
+
if (existsSync(piPkgFile)) {
|
|
376
|
+
const bridge = JSON.parse(readFileSync(piPkgFile, "utf8")).version;
|
|
377
|
+
if (bridge !== OATS_VERSION) console.log(`WARNING: version skew — kernel ${OATS_VERSION}, pi bridge ${bridge}; run \`oats update\` (they publish in lockstep)\n`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log("Config chain (closest first):");
|
|
381
|
+
if (chain.length === 0) console.log(" (none — no oats-config.yaml found walking up)");
|
|
382
|
+
for (const c of chain) {
|
|
383
|
+
console.log(` ${shortPath(c._file)} [${levelOf(c._level)}]`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// An empty-looking chain over oas-* files is not an empty scope: it is a
|
|
387
|
+
// pre-rename OAS deployment this kernel cannot read (aweb-abfy.1).
|
|
388
|
+
const oasScopes = detectOasScopes(ctx);
|
|
389
|
+
if (oasScopes.length) {
|
|
390
|
+
console.log("");
|
|
391
|
+
for (const f of oasScopes) console.log(`UN-MIGRATED OAS SCOPE: ${shortPath(f.dir)} (${f.files.join(", ")})`);
|
|
392
|
+
console.log(` ${OAS_SCOPE_REMEDY}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (r.team) console.log(`\nTeam: ${r.team.name}${r.team.id ? ` (id: ${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]`);
|
|
396
|
+
|
|
397
|
+
console.log("\nLayers:");
|
|
398
|
+
for (const layer of LAYERS) {
|
|
399
|
+
const l = r.layers[layer];
|
|
400
|
+
const prov = r.provenance[layer];
|
|
401
|
+
if (!prov) { console.log(` ${layer.padEnd(10)} (unresolved — no declaration in chain)`); continue; }
|
|
402
|
+
if (!l) { console.log(` ${layer.padEnd(10)} none [${prov}]`); continue; }
|
|
403
|
+
console.log(` ${layer.padEnd(10)} ${l.id} [${prov}]`);
|
|
404
|
+
if (l.inject) console.log(` inject: ${shortPath(l.inject)}`);
|
|
405
|
+
const skills = Array.isArray(l.skills) ? l.skills : (l.skills ? [l.skills] : []);
|
|
406
|
+
if (skills.length) console.log(` skills: ${skills.map(shortPath).join(", ")}`);
|
|
407
|
+
const hooks = Object.keys(l.hooks || {});
|
|
408
|
+
if (hooks.length) console.log(` hooks: ${hooks.join(", ")}`);
|
|
409
|
+
for (const miss of l.missingRequires || []) {
|
|
410
|
+
console.log(` MISSING REQUIREMENT: ${miss.command} — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
console.log("\nKernel injection:");
|
|
415
|
+
console.log(` oats: ${r.kernelInjection?.inject ? shortPath(r.kernelInjection.inject) : "none"} [${r.kernelInjection?.provenance || "default"}]`);
|
|
416
|
+
|
|
417
|
+
console.log("\nUnconditional injections (outermost→innermost):");
|
|
418
|
+
if (r.injects.length === 0) console.log(" (none)");
|
|
419
|
+
for (const inj of r.injects) console.log(` ${inj.source}: ${shortPath(inj.file)}`);
|
|
420
|
+
|
|
421
|
+
for (const mode of ["worktree", "checkout", "attached", "workspace"]) {
|
|
422
|
+
const wm = resolveWorkMode(ctx, mode);
|
|
423
|
+
console.log(`\nWork mode ${mode}: inject ${wm.inject ? shortPath(wm.inject) : "none"}${wm.setup ? `, setup ${shortPath(wm.setup)}` : ""}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log("\nActive capabilities:");
|
|
427
|
+
if (!r.capabilities.length) console.log(" (none)");
|
|
428
|
+
for (const cap of r.capabilities) {
|
|
429
|
+
console.log(` ${cap.id}${cap.layer ? ` layer: ${cap.layer}` : ""} [${cap.provenance.join(" + ")}]`);
|
|
430
|
+
console.log(` trust: ${cap.trust.trusted ? "approved" : `BLOCKED (${cap.trust.reason})`}`);
|
|
431
|
+
if (cap.inject) console.log(` inject: ${shortPath(cap.inject)}`);
|
|
432
|
+
if (cap.skills.length) console.log(` skills: ${cap.skills.map(shortPath).join(", ")}`);
|
|
433
|
+
}
|
|
434
|
+
console.log("\nAcquired capability packages:");
|
|
435
|
+
for (const [name, m] of Object.entries(capabilityManifests(ctx))) {
|
|
436
|
+
const missing = capabilityMissingRequires(name, ctx);
|
|
437
|
+
console.log(` ${name.padEnd(16)} layer: ${(m.layer || "additive").padEnd(10)} origin: ${m._origin}${missing.length ? ` (missing: ${missing.map((x) => x.command).join(", ")})` : ""}`);
|
|
438
|
+
const retiredReason = retiredCapabilityReason(name);
|
|
439
|
+
if (retiredReason) {
|
|
440
|
+
const installed = String(m._origin).startsWith("installed:");
|
|
441
|
+
console.log(` WARNING: artifact of a retired capability — ${retiredReason}${installed ? `; also delete ${shortPath(m._dir)}` : ` (origin ${m._origin}: remove its declaration; the source tree at ${shortPath(m._dir)} is yours to keep or drop)`}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// readCapabilityLocks fails closed on invalid legacy entries — doctor is the
|
|
445
|
+
// diagnosis surface, so catch the typed error and render it (never using the data).
|
|
446
|
+
let locks = {};
|
|
447
|
+
try { locks = readCapabilityLocks(ctx); }
|
|
448
|
+
catch (e) {
|
|
449
|
+
if (e.code !== "invalid-lock") throw e;
|
|
450
|
+
const prov = Array.isArray(e.provenance) ? e.provenance[0] : undefined;
|
|
451
|
+
console.log(` ERROR: ${e.message} [invalid-lock]`);
|
|
452
|
+
if (prov?.file) console.log(` fix or remove the entry in ${shortPath(prov.file)} — never auto-repaired; legacy trust/restore fail closed until it is valid`);
|
|
453
|
+
}
|
|
454
|
+
const mans = capabilityManifests(ctx);
|
|
455
|
+
for (const [id, lock] of Object.entries(locks)) {
|
|
456
|
+
const retiredReason = retiredCapabilityReason(id);
|
|
457
|
+
if (retiredReason) { console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but ${retiredReason}`); continue; }
|
|
458
|
+
if (!mans[id]) console.log(` WARNING: ${id} is locked in ${shortPath(lock._file)} but not acquired — run \`oats install\``);
|
|
459
|
+
}
|
|
460
|
+
for (const [id, m] of Object.entries(mans)) {
|
|
461
|
+
if (!String(m._origin).startsWith("installed:")) continue;
|
|
462
|
+
// SCOPE-EXACT on the v2 side. `m._capabilityLock` is the row from the
|
|
463
|
+
// artifact's OWN scope's lock (capabilityManifests annotates it there), and
|
|
464
|
+
// that is the only row that can lock this artifact: the merged chain would
|
|
465
|
+
// let an outer scope's lock — or a lock-only ancestor with no config at all
|
|
466
|
+
// — silence an unlocked inner copy that WINS discovery precedence and
|
|
467
|
+
// activates. The legacy arm stays chain-merged: v1 parity is unchanged.
|
|
468
|
+
if (m._capabilityLock || Object.hasOwn(locks, id)) continue;
|
|
469
|
+
console.log(` WARNING: ${id} at ${shortPath(m._dir)} is in installed/ but has no lock entry — reacquire it or move it to owned/`);
|
|
470
|
+
}
|
|
471
|
+
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`);
|
|
472
|
+
|
|
473
|
+
// Distribution packages: package failures are distinguished from capability
|
|
474
|
+
// failures. Doctor is the DIAGNOSIS surface — human and JSON render the SAME
|
|
475
|
+
// doctorPackagesData computation (reviewer-455ba15 fix 4); fail-closed
|
|
476
|
+
// invalid-lock raises are diagnosed here, never consumed as data.
|
|
477
|
+
console.log("\nInstalled packages:");
|
|
478
|
+
const pkg = doctorPackagesData(ctx, chain, { teamScope: r.team?.scope });
|
|
479
|
+
if (pkg.lockError) {
|
|
480
|
+
console.log(` ERROR: ${pkg.lockError.message} [${pkg.lockError.code}]`);
|
|
481
|
+
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`);
|
|
482
|
+
}
|
|
483
|
+
if (!pkg.lockError && !pkg.packages.length && !pkg.legacyLockFiles.length) console.log(" (none)");
|
|
484
|
+
for (const p of pkg.packages) {
|
|
485
|
+
console.log(` ${p.id}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]`);
|
|
486
|
+
for (const prob of p.problems) {
|
|
487
|
+
if (prob.code === "untrusted-surface") console.log(` ${prob.detail}`);
|
|
488
|
+
else console.log(` ERROR: ${prob.detail} [${prob.code}]`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (const l of pkg.legacyLockFiles) {
|
|
492
|
+
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)`);
|
|
493
|
+
else console.log(` WARNING: ${shortPath(l.file)} is lockfileVersion ${l.lockfileVersion} — \`oats migrate\` maps its capability locks to packages`);
|
|
494
|
+
}
|
|
495
|
+
if (pkg.officialMigration) {
|
|
496
|
+
const om = pkg.officialMigration;
|
|
497
|
+
console.log(`\nOfficial capability migration (0.18 bundled capabilities → official packages):`);
|
|
498
|
+
for (const c of om.capabilities) {
|
|
499
|
+
console.log(` ${c.capability} → package ${c.package}${c.via === "alias" ? " (catalog alias)" : ""} ${c.available ? "[mapped]" : "[no catalog mapping yet]"} [${shortPath(c.level)}]`);
|
|
500
|
+
}
|
|
501
|
+
if (om.status === "ready") console.log(` READY: migrate with \`${om.command}\` (plan it first with --dry-run; approvals are re-earned afterwards)`);
|
|
502
|
+
else console.log(` NOT YET AVAILABLE: ${om.reason}`);
|
|
503
|
+
}
|
|
504
|
+
for (const a of pkg.adoptedTemplates) {
|
|
505
|
+
if (a.status === "broken") {
|
|
506
|
+
console.log(`\nAdopted config template: BROKEN at ${shortPath(a.level)} — ${a.detail}`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const drift = a.localChanges === null ? "" : a.localChanges ? " — local edits present (`oats config diff`)" : " — no local edits yet";
|
|
510
|
+
console.log(`\nAdopted config template: ${shortPath(a.file)} adopted ${a.package}:${a.template}${a.version ? `@${a.version}` : ""}${drift}`);
|
|
511
|
+
console.log(` recorded base ${shortPath(a.base)} (commit it — \`oats config sync\` compares against it; package updates never rewrite your config)`);
|
|
512
|
+
}
|
|
513
|
+
if (pkg.missingHostRequirements.length) {
|
|
514
|
+
console.log("\nMissing host commands (active capabilities):");
|
|
515
|
+
for (const req of pkg.missingHostRequirements) {
|
|
516
|
+
console.log(` ${req.command} — ${req.why || "required"} (requested by: ${req.requestedBy.map((r) => r.capability).join(", ")})`);
|
|
517
|
+
if (req.plan) console.log(` install with consent: ${req.consentCommand} (runs: ${req.plan.argv.join(" ")})`);
|
|
518
|
+
else if (req.docs) console.log(` install docs: ${req.docs}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (soulName) {
|
|
523
|
+
const composition = doctorComposition(ctx, soulName);
|
|
524
|
+
console.log(`\nFinal composed AGENTS.md for ${soulName}:\n\n${composition.text}`);
|
|
525
|
+
} else console.log("\nPass --soul <name> to inspect final composed AGENTS.md.");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------- config editing (structural: parse → mutate → re-serialize the capabilities block) ----------
|
|
529
|
+
function originToFrom(origin) {
|
|
530
|
+
const o = String(origin || "");
|
|
531
|
+
if (o.startsWith("installed:")) return "installed";
|
|
532
|
+
if (o.startsWith("owned:")) return "owned";
|
|
533
|
+
if (o.startsWith("path:")) return undefined; // path declarations stay hand-authored
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function serializeBinding(value, indent) {
|
|
538
|
+
if (value === true || value === false) return ` ${value}`;
|
|
539
|
+
const lines = [""];
|
|
540
|
+
if (value.enabled !== undefined) lines.push(`${indent}enabled: ${value.enabled}`);
|
|
541
|
+
if (value.settings && Object.keys(value.settings).length) {
|
|
542
|
+
lines.push(`${indent}settings:`);
|
|
543
|
+
for (const [k, v] of Object.entries(value.settings)) lines.push(`${indent} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
|
|
544
|
+
}
|
|
545
|
+
return lines.join("\n");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Serialize one capability entry map at the given base indent, with the conventional injection comment. */
|
|
549
|
+
function serializeCapabilityEntry(id, entry, baseIndent) {
|
|
550
|
+
const i = baseIndent;
|
|
551
|
+
const lines = [];
|
|
552
|
+
if (entry.capability) lines.push(`${i}capability: ${entry.capability}`);
|
|
553
|
+
if (entry.from) lines.push(`${i}from: ${entry.from}`);
|
|
554
|
+
if (entry.global !== undefined) lines.push(`${i}global:${serializeBinding(entry.global, i + " ")}`);
|
|
555
|
+
const types = entry["agent-types"];
|
|
556
|
+
if (types && Object.keys(types).length) {
|
|
557
|
+
lines.push(`${i}agent-types:`);
|
|
558
|
+
for (const [t, v] of Object.entries(types)) lines.push(`${i} ${t}:${serializeBinding(v, i + " ")}`);
|
|
559
|
+
}
|
|
560
|
+
if (entry.souls && Object.keys(entry.souls).length) {
|
|
561
|
+
lines.push(`${i}souls:`);
|
|
562
|
+
for (const [s, v] of Object.entries(entry.souls)) lines.push(`${i} ${s}:${serializeBinding(v, i + " ")}`);
|
|
563
|
+
}
|
|
564
|
+
if (entry.settings && Object.keys(entry.settings).length) {
|
|
565
|
+
lines.push(`${i}settings:`);
|
|
566
|
+
for (const [k, v] of Object.entries(entry.settings)) lines.push(`${i} ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
|
|
567
|
+
}
|
|
568
|
+
if (entry["injection-override"] !== undefined) lines.push(`${i}injection-override: ${entry["injection-override"]}`);
|
|
569
|
+
else if (entry.from === "owned" || String(entry.from || "").startsWith("path:"))
|
|
570
|
+
lines.push(`${i}# injection edited at source: .agents/capabilities/owned/${id}/injects/`);
|
|
571
|
+
else lines.push(`${i}# injection-override: .agents/injections/capabilities/${id}.md`);
|
|
572
|
+
return lines;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Re-serialize the whole `capabilities:` block from its parsed model. */
|
|
576
|
+
function serializeCapabilities(caps) {
|
|
577
|
+
const lines = ["capabilities:", " # Fundamental layers — exclusive slots; a capability entry or an explicit none.", " layers:"];
|
|
578
|
+
for (const layer of LAYERS) {
|
|
579
|
+
const entry = caps.layers?.[layer];
|
|
580
|
+
if (entry === undefined) continue;
|
|
581
|
+
if (entry === "none") { lines.push(` ${layer}: none`); continue; }
|
|
582
|
+
lines.push(` ${layer}:`);
|
|
583
|
+
lines.push(...serializeCapabilityEntry(entry.capability, entry, " "));
|
|
584
|
+
}
|
|
585
|
+
const additive = Object.entries(caps.additive || {});
|
|
586
|
+
if (additive.length) {
|
|
587
|
+
lines.push(" additive:");
|
|
588
|
+
for (const [id, entry] of additive) {
|
|
589
|
+
lines.push(` ${id}:`);
|
|
590
|
+
lines.push(...serializeCapabilityEntry(id, entry, " "));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return lines.join("\n") + "\n";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Replace (or append) the top-level capabilities: block in config text. */
|
|
597
|
+
function replaceCapabilitiesBlock(text, caps) {
|
|
598
|
+
const serialized = serializeCapabilities(caps);
|
|
599
|
+
const lines = text.replace(/\n*$/, "\n").split("\n");
|
|
600
|
+
const start = lines.findIndex((l) => /^capabilities:\s*(#.*)?$/.test(l));
|
|
601
|
+
if (start < 0) return text.replace(/\n*$/, "\n\n") + serialized;
|
|
602
|
+
let end = lines.length;
|
|
603
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
604
|
+
if (/^[^\s#]/.test(lines[i])) { end = i; break; }
|
|
605
|
+
if (/^#/.test(lines[i]) && i + 1 < lines.length && /^[^\s]/.test(lines[i + 1] || "")) { end = i; break; }
|
|
606
|
+
}
|
|
607
|
+
return [...lines.slice(0, start), ...serialized.replace(/\n$/, "").split("\n"), "", ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Load the parsed capabilities model of a config file ({layers:{}, additive:{}}). */
|
|
611
|
+
function readCapabilitiesModel(file) {
|
|
612
|
+
if (!existsSync(file)) return { layers: {}, additive: {} };
|
|
613
|
+
const cfg = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8")));
|
|
614
|
+
const caps = cfg.capabilities || {};
|
|
615
|
+
return { layers: { ...(caps.layers || {}) }, additive: { ...(caps.additive || {}) } };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------- use / activation ----------
|
|
619
|
+
function use() {
|
|
620
|
+
const requested = args[1];
|
|
621
|
+
if (!requested || requested.startsWith("--")) die("usage: oats use <capability|none> [--global|--type <agent-type>|--soul <name>] [--disable] [--layer <name>] [--settings k=v [k2=v2 ...]] [--dir <dir>]");
|
|
622
|
+
const dir = dirFlag();
|
|
623
|
+
const level = levelOf(dir);
|
|
624
|
+
const file = join(dir, "oats-config.yaml");
|
|
625
|
+
const layer = flag("layer");
|
|
626
|
+
if (layer && !LAYERS.includes(layer)) die(`--layer must be one of: ${LAYERS.join(", ")}`);
|
|
627
|
+
let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
|
|
628
|
+
const caps = readCapabilitiesModel(file);
|
|
629
|
+
if (requested === "none") {
|
|
630
|
+
if (!layer) die("oats use none requires --layer <name>");
|
|
631
|
+
caps.layers[layer] = "none";
|
|
632
|
+
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
633
|
+
console.log(`Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const manifest = capabilityManifest(requested, dir);
|
|
637
|
+
if (!manifest) {
|
|
638
|
+
// A scope with NO oats-config.yaml anywhere in its chain is not a config
|
|
639
|
+
// level, so `capabilityManifests` — which walks the chain — never opens this
|
|
640
|
+
// scope's installed store: a capability acquired and locked right here would
|
|
641
|
+
// otherwise be reported as never acquired. Diagnose the missing chain
|
|
642
|
+
// instead. `oats use` still writes nothing: authoring an adopter's first
|
|
643
|
+
// config is `oats init`'s job, and guessing it here would be policy.
|
|
644
|
+
if (!configChain(dir).length && ownScopeCapabilityManifest(dir, requested)) {
|
|
645
|
+
// The remedy is `--raw` on purpose: it is offline, deterministic, and
|
|
646
|
+
// writes only the minimal config this scope is missing. It never names
|
|
647
|
+
// `--package <pkg>` — that arm read the provider out of the MERGED lock
|
|
648
|
+
// chain while this gate reads own-scope only, and it dead-ends whenever
|
|
649
|
+
// the provider exports no config template. Both scope mentions use the
|
|
650
|
+
// same rendering, so the printed command is copyable verbatim.
|
|
651
|
+
cmdFail("E_NO_CONFIG", `capability "${requested}" is present in the capability store at ${shellQuote(dir)}, but there is no oats-config.yaml at this scope or any level above it — \`oats use\` activates into a config file and this scope has none. Create the minimal one with \`oats init --raw --dir ${shellQuote(dir)}\`, then re-run \`oats use ${requested}\`.`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
die(`unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
|
|
655
|
+
}
|
|
656
|
+
if (layer && manifest.layer !== layer) die(`capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
|
|
657
|
+
const targets = [["agent-types", flag("type")], ["souls", flag("soul")]].filter(([, value]) => value);
|
|
658
|
+
if (args.includes("--global")) targets.push(["global", undefined]);
|
|
659
|
+
if (targets.length > 1) die("choose exactly one of --global, --type, or --soul");
|
|
660
|
+
const [targetKind, targetName] = targets[0] || ["global", undefined];
|
|
661
|
+
const enabled = !args.includes("--disable");
|
|
662
|
+
// Locate or create the entry in the right subtree.
|
|
663
|
+
let entry;
|
|
664
|
+
if (manifest.layer) {
|
|
665
|
+
const existing = caps.layers[manifest.layer];
|
|
666
|
+
entry = existing && existing !== "none" && existing.capability === manifest.capability ? existing : { capability: manifest.capability };
|
|
667
|
+
if (existing && existing !== "none" && existing.capability !== manifest.capability && enabled) {
|
|
668
|
+
die(`fundamental layer ${manifest.layer} already binds ${existing.capability} at this level — disable it first`);
|
|
669
|
+
}
|
|
670
|
+
caps.layers[manifest.layer] = entry;
|
|
671
|
+
} else {
|
|
672
|
+
entry = caps.additive[manifest.capability] || {};
|
|
673
|
+
caps.additive[manifest.capability] = entry;
|
|
674
|
+
}
|
|
675
|
+
const from = originToFrom(manifest._origin);
|
|
676
|
+
if (from && !entry.from) entry.from = from;
|
|
677
|
+
const settingsArgs = [];
|
|
678
|
+
for (let i = 0; i < args.length; i++) {
|
|
679
|
+
if (args[i] !== "--settings") continue;
|
|
680
|
+
let consumed = 0;
|
|
681
|
+
for (let j = i + 1; j < args.length && !args[j].startsWith("--"); j++, consumed++) settingsArgs.push(args[j]);
|
|
682
|
+
if (!consumed) die("--settings expects one or more key=value pairs");
|
|
683
|
+
i += consumed;
|
|
684
|
+
}
|
|
685
|
+
if (settingsArgs.length) {
|
|
686
|
+
entry.settings = entry.settings && typeof entry.settings === "object" ? entry.settings : {};
|
|
687
|
+
for (const kv of settingsArgs) {
|
|
688
|
+
const eq = kv.indexOf("=");
|
|
689
|
+
if (eq <= 0) die(`--settings expects key=value, got "${kv}"`);
|
|
690
|
+
// WRITE side of the refusals the readers enforce. Two distinct hazards on
|
|
691
|
+
// this one line:
|
|
692
|
+
// - `--settings __proto__=x` assigned through the inherited setter,
|
|
693
|
+
// which swallowed the entry, and the command reported success for a
|
|
694
|
+
// setting it never wrote;
|
|
695
|
+
// - the VALUE is rendered verbatim into one `key: value` line, so a
|
|
696
|
+
// newline-bearing value stopped being a value and became document —
|
|
697
|
+
// a crafted one added a whole second capability entry.
|
|
698
|
+
// Both fail closed, before anything is written.
|
|
699
|
+
const key = assertSafeConfigWriteKey(kv.slice(0, eq), `--settings key ${JSON.stringify(kv.slice(0, eq))}`);
|
|
700
|
+
entry.settings[key] = assertSafeConfigValue(kv.slice(eq + 1), `--settings value for ${JSON.stringify(key)}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (targetKind === "global") entry.global = enabled;
|
|
704
|
+
else {
|
|
705
|
+
// A layer entry with no explicit targets is implicitly global — materialize that
|
|
706
|
+
// before narrowing, so adding a soul/type binding doesn't silently drop everyone else.
|
|
707
|
+
if (manifest.layer && entry.global === undefined && !entry["agent-types"] && !entry.souls) entry.global = true;
|
|
708
|
+
entry[targetKind] = entry[targetKind] && typeof entry[targetKind] === "object" ? entry[targetKind] : {};
|
|
709
|
+
// Same write-side refusal, and for the same two reasons: `--soul
|
|
710
|
+
// __proto__` was swallowed by the inherited setter and reported as
|
|
711
|
+
// activated, and a `--soul`/`--type` NAME is written as a mapping key, so a
|
|
712
|
+
// newline in it injects document exactly like a settings value does.
|
|
713
|
+
entry[targetKind][assertSafeConfigWriteKey(targetName, `--${targetKind === "agent-types" ? "type" : "soul"} name ${JSON.stringify(String(targetName))}`)] = enabled;
|
|
714
|
+
}
|
|
715
|
+
writeFileSync(file, replaceCapabilitiesBlock(text, caps));
|
|
716
|
+
console.log(`${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
|
|
717
|
+
for (const miss of capabilityMissingRequires(manifest.capability, dir)) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
|
|
718
|
+
console.log("New instances receive the resolved capability; committed souls are unchanged.");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------- install / trust / list / remove / migrate ----------
|
|
722
|
+
const cmdFail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
723
|
+
/** `oats install <source>`: distribution-package acquisition (exact-lock closure,
|
|
724
|
+
* activates nothing). Marketplace capability ids keep the legacy capability path
|
|
725
|
+
* until workstream 3 publishes the official packages. */
|
|
726
|
+
function install() {
|
|
727
|
+
const src = args[1];
|
|
728
|
+
const dir = dirFlag();
|
|
729
|
+
if (!src || src.startsWith("--")) {
|
|
730
|
+
// Usage errors surface BEFORE any restore/network side effect: a malformed
|
|
731
|
+
// --accept-requirement must not mutate the deployment and then report E_USAGE.
|
|
732
|
+
flagAll("accept-requirement");
|
|
733
|
+
reconcile(dir);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const retiredReason = retiredCapabilityReason(src);
|
|
737
|
+
if (retiredReason) cmdFail("retired-capability", retiredReason);
|
|
738
|
+
// Package source? (git/path with an oats-package.json, or a catalog id) — otherwise legacy capability acquisition.
|
|
739
|
+
let parsedSrc;
|
|
740
|
+
try { parsedSrc = parsePackageSource(src); } catch { parsedSrc = undefined; }
|
|
741
|
+
const catalogId = parsedSrc?.kind === "catalog" ? parsedSrc.id : undefined;
|
|
742
|
+
const hasOfficialPackage = !!catalogId && Object.hasOwn(officialPackageCatalog(), catalogId);
|
|
743
|
+
// Once an official package catalog entry exists it becomes the default
|
|
744
|
+
// acquisition route for that short id. Existing v1 installs keep working,
|
|
745
|
+
// but a deliberate `oats install oats.okf` now acquires the package rather than
|
|
746
|
+
// creating another legacy capability lock.
|
|
747
|
+
const isMarketplaceCap = parsedSrc?.kind === "catalog" && !!marketplaceCapabilities()[catalogId] && !hasOfficialPackage;
|
|
748
|
+
const isLocalPackage = parsedSrc?.kind === "path" && existsSync(join(parsedSrc.path, "oats-package.json"));
|
|
749
|
+
const isCatalogPackage = parsedSrc?.kind === "catalog" && !isMarketplaceCap;
|
|
750
|
+
let gitInspection;
|
|
751
|
+
if (parsedSrc && (parsedSrc.kind === "git" || isLocalPackage || isCatalogPackage)) {
|
|
752
|
+
// Remote Git may be either a distribution package or the documented
|
|
753
|
+
// legacy standalone-capability repository. Inspect the fetched ROOT before
|
|
754
|
+
// any scope lock preflight; never infer root layout from closure errors.
|
|
755
|
+
if (parsedSrc.kind === "git") {
|
|
756
|
+
try { gitInspection = inspectGitSourceRoot(src); }
|
|
757
|
+
catch (e) { cmdFail(e.code || "invalid-source", e.message || e); return; }
|
|
758
|
+
if (gitInspection.payloadPackage) {
|
|
759
|
+
try { installPackage(dir, src, { rootSnapshot: gitInspection }); }
|
|
760
|
+
finally { gitInspection.cleanup(); }
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
// Legacy standalone-capability repositories predate contained package
|
|
764
|
+
// roots, so the fallback only applies to a REPOSITORY-ROOT capability
|
|
765
|
+
// that was not asked for a specific path. A repo whose root carries
|
|
766
|
+
// oats-package.json must never silently downgrade to capability
|
|
767
|
+
// acquisition just because the selected path holds no package.
|
|
768
|
+
if (gitInspection.explicitPath || gitInspection.package || !gitInspection.capability) {
|
|
769
|
+
const where = `package path "${gitInspection.path}"`;
|
|
770
|
+
const reason = gitInspection.package
|
|
771
|
+
? `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}#.\``
|
|
772
|
+
: gitInspection.explicitPath
|
|
773
|
+
? `Git source ${src} has no oats-package.json at ${where}`
|
|
774
|
+
: `Git source ${src} has no oats-package.json at ${where} (the default package path) and no oats.json at its root`;
|
|
775
|
+
gitInspection.cleanup();
|
|
776
|
+
cmdFail("invalid-package-manifest", reason); return;
|
|
777
|
+
}
|
|
778
|
+
// Standalone capability: hand the SAME fetched snapshot to legacy
|
|
779
|
+
// acquisition (which re-verifies that exact root layout before copying).
|
|
780
|
+
} else { installPackage(dir, src); return; }
|
|
781
|
+
}
|
|
782
|
+
let known;
|
|
783
|
+
try { known = gitInspection ? undefined : capabilityManifest(src, dir); }
|
|
784
|
+
catch (e) { gitInspection?.cleanup(); cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
785
|
+
if (known) {
|
|
786
|
+
if (JSON_MODE) { jsonOk({ alreadyAcquired: known.capability, version: known.version || null }); return; }
|
|
787
|
+
console.log(`Already acquired capability ${known.capability} (${known.version || "unversioned"}); not activated or updated.`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
let r;
|
|
791
|
+
try { r = acquireCapability(dir, src, { rootSnapshot: gitInspection }); }
|
|
792
|
+
catch (e) { cmdFail(e.code || "invalid-source", e.message); return; }
|
|
793
|
+
finally { gitInspection?.cleanup(); }
|
|
794
|
+
const lock = {
|
|
795
|
+
source: r.source,
|
|
796
|
+
version: r.manifest.version || null,
|
|
797
|
+
...(r.commit ? { commit: r.commit } : {}), integrity: r.integrity,
|
|
798
|
+
// Marketplace packages ship with the kernel you already installed — they are
|
|
799
|
+
// trusted at acquisition; third-party git/path installs need explicit `oats trust`.
|
|
800
|
+
trustedExecutables: !!r.marketplace,
|
|
801
|
+
};
|
|
802
|
+
if (r.marketplace && r.manifest.environment?.length) {
|
|
803
|
+
(JSON_MODE ? console.error : console.log)(`Requested launch environment: ${r.manifest.environment.join(", ")}`);
|
|
804
|
+
}
|
|
805
|
+
let lockFile;
|
|
806
|
+
try { lockFile = writeCapabilityLock(dir, r.manifest.capability, lock); }
|
|
807
|
+
catch (e) {
|
|
808
|
+
// Refused lock write (e.g. legacy-lock: a converted scope rejects a NEW v1
|
|
809
|
+
// capability entry) must
|
|
810
|
+
// not strand the acquired artifact — compensate before failing.
|
|
811
|
+
rmSync(r.dest, { recursive: true, force: true });
|
|
812
|
+
cmdFail(e.code || "legacy-lock", e.message); return;
|
|
813
|
+
}
|
|
814
|
+
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; }
|
|
815
|
+
console.log(`Acquired ${r.manifest.capability} → ${shortPath(r.dest)}`);
|
|
816
|
+
console.log(`Locked ${r.manifest.version || r.commit || "exact artifact"} (${r.integrity}) in ${shortPath(lockFile)}; not activated.`);
|
|
817
|
+
if (r.marketplace) console.log("Marketplace package: executables trusted at acquisition.");
|
|
818
|
+
else if (r.manifest.commands || r.manifest.hooks || r.manifest.environment?.length) {
|
|
819
|
+
if (r.manifest.environment?.length) console.log(`Future trust request includes launch environment: ${r.manifest.environment.join(", ")}`);
|
|
820
|
+
console.log(`Executable surface is blocked until: oats trust ${r.manifest.capability} --dir ${shortPath(dir)}`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Lock-file levels from dir upward (closest last — outermost first), like restoreCapabilities' walk. */
|
|
825
|
+
function lockLevelsUp(dir) {
|
|
826
|
+
const levels = [];
|
|
827
|
+
for (let d = resolve(dir); ; d = dirname(d)) {
|
|
828
|
+
if (existsSync(join(d, OATS_LOCK_FILE))) levels.push(d);
|
|
829
|
+
if (dirname(d) === d) break;
|
|
830
|
+
}
|
|
831
|
+
return levels.reverse();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Check/restore one level's v2 package locks via the ENGINE's restorePackages
|
|
835
|
+
* (exact restore, no ref advancement, staging + integrity/capability/deps
|
|
836
|
+
* verification inside). The engine walks the lock chain from the given dir;
|
|
837
|
+
* reconciliation calls it per deduplicated level and keeps that level's rows. */
|
|
838
|
+
/** Map engine restore rows to WS2 report items (kind package). */
|
|
839
|
+
const pkgRow = (r) => ({
|
|
840
|
+
id: r.package, level: r.level, package: true, dir: r.dir,
|
|
841
|
+
status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
/** Restore-and-partition for reconciliation (reviewer-455ba15 fix 1): the
|
|
845
|
+
* engine's restorePackages walks the WHOLE lock chain from a directory and has
|
|
846
|
+
* no exact-level option, so invoke it ONCE per deepest scope and PARTITION the
|
|
847
|
+
* report rows by lock level — never re-invoke per level (each re-invocation
|
|
848
|
+
* re-runs restore side effects for every ancestor lock). Returns a Map
|
|
849
|
+
* level(resolved) → rows. */
|
|
850
|
+
function partitionedPackageRestore(deepestDir) {
|
|
851
|
+
const byLevel = new Map();
|
|
852
|
+
const add = (level, row) => {
|
|
853
|
+
const key = resolve(level);
|
|
854
|
+
if (!byLevel.has(key)) byLevel.set(key, []);
|
|
855
|
+
byLevel.get(key).push(row);
|
|
856
|
+
};
|
|
857
|
+
for (const r of restorePackages(deepestDir)) add(r.level, pkgRow(r));
|
|
858
|
+
// EMPTY v1 lock files surface too (maintainer ruling): the engine's restore
|
|
859
|
+
// report only rows NON-empty v1 files. Walk the raw lock chain (a lock-only
|
|
860
|
+
// scope has no config, so configChain-based reads cannot see it) and emit a
|
|
861
|
+
// LEGACY row for each empty v1 file so reconciliation shows the pending
|
|
862
|
+
// lock-format migration.
|
|
863
|
+
for (const level of lockLevelsUp(deepestDir)) {
|
|
864
|
+
try {
|
|
865
|
+
const parsed = JSON.parse(readFileSync(join(level, OATS_LOCK_FILE), "utf8"));
|
|
866
|
+
if (parsed.lockfileVersion !== 2 && !Object.keys(parsed.capabilities || {}).length) {
|
|
867
|
+
add(level, { id: null, level, package: true, status: "legacy", reason: `empty lockfileVersion ${parsed.lockfileVersion ?? 1} file — pending lock-format migration: oats migrate --dir ${level}` });
|
|
868
|
+
}
|
|
869
|
+
} catch { /* malformed locks raise via restorePackages above */ }
|
|
870
|
+
}
|
|
871
|
+
return byLevel;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function installPackage(dir, src, opts = {}) {
|
|
875
|
+
const bail = (e) => (JSON_MODE ? jsonFail(e.code || "invalid-source", e.message || e) : die(e.message || e));
|
|
876
|
+
let r;
|
|
877
|
+
try { r = acquirePackage(dir, src, opts); }
|
|
878
|
+
catch (e) { bail(e); return true; }
|
|
879
|
+
// Packages are transport; capabilities are what lands on disk. Report both,
|
|
880
|
+
// and let the CAPABILITY rows carry the provenance an operator acts on.
|
|
881
|
+
if (JSON_MODE) { jsonOk({ root: r.root, installed: r.installed, capabilities: r.capabilities, lockFile: r.lockFile, depWarnings: r.depWarnings || [] }); return true; }
|
|
882
|
+
for (const p of r.installed) {
|
|
883
|
+
console.log(`${p.kept ? "ok " : "Acquired "}${p.package}@${p.version}`);
|
|
884
|
+
console.log(` locked ${p.commit === "local" ? "local tree" : p.commit} at path ${p.path} (${p.integrity})`);
|
|
885
|
+
for (const c of r.capabilities.filter((x) => x.package === p.package)) {
|
|
886
|
+
console.log(` capability ${c.capability}@${c.version}${c.layer ? ` layer: ${c.layer}` : ""} → ${shortPath(c.dir)} (${c.integrity})`);
|
|
887
|
+
}
|
|
888
|
+
if (!r.capabilities.some((x) => x.package === p.package)) console.log(" capabilities: (none)");
|
|
889
|
+
}
|
|
890
|
+
for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
|
|
891
|
+
console.log(`Locked in ${shortPath(r.lockFile)}; nothing activated.`);
|
|
892
|
+
// Read the executable surface off the ENGINE's projection, not a config-chain
|
|
893
|
+
// manifest lookup: at a scope with no config yet, that lookup sees nothing.
|
|
894
|
+
const executables = r.capabilities
|
|
895
|
+
.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
|
|
896
|
+
.map((c) => c.capability);
|
|
897
|
+
if (executables.length) console.log(`Executable surfaces blocked until trusted: ${executables.map((c) => `oats trust ${c}`).join("; ")}`);
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Bare `oats install` chain restore: engine packages (lock v2) + legacy locked
|
|
902
|
+
* capabilities (v1). Returns { report, failed }; output goes to stdout (human)
|
|
903
|
+
* or stderr (JSON mode) — the reconcile envelope owns stdout in JSON mode. */
|
|
904
|
+
function restore(dir) {
|
|
905
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
906
|
+
// Fail-closed locks: restorePackages/restoreCapabilities RAISE typed
|
|
907
|
+
// invalid-lock — let the reconcile boundary surface the code verbatim
|
|
908
|
+
// (never softened to empty); this throw is caught by reconcile().
|
|
909
|
+
const pkgReport = restorePackages(dir).map((r) => ({
|
|
910
|
+
id: r.package, level: r.level, package: true, dir: r.dir,
|
|
911
|
+
status: r.status === "ok" ? "present" : r.status, reason: r.reason, code: r.code,
|
|
912
|
+
}));
|
|
913
|
+
const report = [...restoreCapabilities(dir), ...pkgReport];
|
|
914
|
+
if (!report.length) note("Nothing to restore — no locked capabilities in the config chain.");
|
|
915
|
+
let failed = 0;
|
|
916
|
+
for (const r of report) {
|
|
917
|
+
const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
|
|
918
|
+
if (r.status === "present") note(`ok ${what} (${shortPath(r.dir)})`);
|
|
919
|
+
else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)}${r.integrity ? ` (${r.integrity})` : ""}`);
|
|
920
|
+
else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
|
|
921
|
+
else if (r.status === "retired") { failed++; note(`RETIRED ${what} ${r.reason}`); }
|
|
922
|
+
else { failed++; note(`FAILED ${what} ${r.reason}`); }
|
|
923
|
+
}
|
|
924
|
+
return { report, failed };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Unsuccessful restore statuses and their frozen taxonomy codes (reviewer-6f0a3bd:
|
|
928
|
+
* "unrestorable" and "retired" must not report ok). */
|
|
929
|
+
const UNSUCCESSFUL_RESTORE = { failed: undefined, unrestorable: "invalid-source", retired: "retired-capability" };
|
|
930
|
+
|
|
931
|
+
/** One artifact report item → the machine shape (kind capability|package). */
|
|
932
|
+
const artifactJson = (r) => ({
|
|
933
|
+
id: r.id, kind: r.package ? "package" : "capability", level: r.level,
|
|
934
|
+
status: r.status, ...(r.dir ? { dir: r.dir } : {}), ...(r.reason ? { reason: r.reason } : {}),
|
|
935
|
+
...(Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status) ? { code: r.code || UNSUCCESSFUL_RESTORE[r.status] || "integrity-drift" } : {}),
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
/** Emit the reconcile/restore result: human exit or the single-envelope JSON contract.
|
|
939
|
+
* Full success → { ok: true, result }. ANY artifact or consented-install failure →
|
|
940
|
+
* nonzero with error.code E_RECONCILE_FAILED and the SAME complete report under
|
|
941
|
+
* error.details — partial outcomes are never lost. */
|
|
942
|
+
function emitReconcileResult({ boundary, boundaryKind, scopes, requirements, failures }) {
|
|
943
|
+
const result = { boundary, boundaryKind, scopes, requirements, failures };
|
|
944
|
+
if (JSON_MODE) {
|
|
945
|
+
if (failures.length) {
|
|
946
|
+
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 } }));
|
|
947
|
+
process.exit(1);
|
|
948
|
+
}
|
|
949
|
+
jsonOk(result);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (failures.length) {
|
|
953
|
+
console.log("\nFailures by scope:");
|
|
954
|
+
for (const f of failures) console.log(` ${shortPath(f.scope)}: ${f.id} — ${f.reason}`);
|
|
955
|
+
die(`${failures.length} failure${failures.length > 1 ? "s" : ""} during restore/reconciliation`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Bare `oats install` at a team boundary: reconcile the whole workspace — restore the
|
|
960
|
+
* boundary scope's graph (its ancestor chain), then every descendant scope's own
|
|
961
|
+
* lock graph EXACTLY ONCE, in deterministic path order, with pruned discovery;
|
|
962
|
+
* verify v2 package locks against the installed package store; validate
|
|
963
|
+
* config-referenced capabilities against visible locked packages; aggregate
|
|
964
|
+
* missing requirements and failures by scope.
|
|
965
|
+
* Non-team scopes keep current-chain behavior unless --recursive names a boundary. */
|
|
966
|
+
/** Bare `oats install` (no source): current-chain restore or team-boundary
|
|
967
|
+
* reconciliation. JSON-mode boundary: ANY throw before emitReconcileResult
|
|
968
|
+
* (malformed lock/config, discovery failures) must still yield the single
|
|
969
|
+
* envelope — never empty stdout with a stack trace. */
|
|
970
|
+
function reconcile(dir) {
|
|
971
|
+
try { reconcileInner(dir); }
|
|
972
|
+
catch (e) {
|
|
973
|
+
if (JSON_MODE) {
|
|
974
|
+
console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code: e.code || "E_RECONCILE_FAILED", message: String(e.message || e) } }));
|
|
975
|
+
process.exit(1);
|
|
976
|
+
}
|
|
977
|
+
die(e.message || e);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function reconcileInner(dir) {
|
|
982
|
+
const cfgFile = join(dir, "oats-config.yaml");
|
|
983
|
+
const declaresTeamHere = existsSync(cfgFile) && !!withConfigFile(cfgFile, () => parseYamlNested(readFileSync(cfgFile, "utf8"))).team;
|
|
984
|
+
const recursive = args.includes("--recursive");
|
|
985
|
+
if (!declaresTeamHere && !recursive) {
|
|
986
|
+
// Current-chain behavior, plus the requirements gate for this chain's active capabilities.
|
|
987
|
+
const { report, failed } = restore(dir);
|
|
988
|
+
const requirements = requirementsGate([dir]);
|
|
989
|
+
const failures = [
|
|
990
|
+
// "legacy" is informational (v1 locks restore via the capability path);
|
|
991
|
+
// every other unsuccessful status is a failure (incl. retired/unrestorable
|
|
992
|
+
// per reviewer-6f0a3bd — they must not report ok).
|
|
993
|
+
...report.filter((r) => Object.hasOwn(UNSUCCESSFUL_RESTORE, r.status)).map((r) => ({ scope: r.level, id: r.package ? `package ${r.id}` : r.id, reason: r.reason, code: r.code || UNSUCCESSFUL_RESTORE[r.status] })),
|
|
994
|
+
...requirements.filter((q) => q.outcome === "failed").map((q) => ({ scope: dir, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" })),
|
|
995
|
+
];
|
|
996
|
+
void failed;
|
|
997
|
+
emitReconcileResult({
|
|
998
|
+
boundary: dir, boundaryKind: "chain",
|
|
999
|
+
scopes: [{ scope: dir, artifacts: report.map(artifactJson) }],
|
|
1000
|
+
requirements, failures,
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const boundary = dir;
|
|
1005
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
1006
|
+
// The chosen boundary is printed BEFORE any network or host work — always.
|
|
1007
|
+
note(`Workspace reconciliation boundary: ${shortPath(boundary)}${declaresTeamHere ? " (team scope)" : " (--recursive)"}`);
|
|
1008
|
+
const scopes = [boundary, ...discoverWorkspaceScopes(boundary)];
|
|
1009
|
+
const failures = [];
|
|
1010
|
+
const scopeReports = [];
|
|
1011
|
+
let reportedAny = false;
|
|
1012
|
+
const restoredLevels = new Set(); // each lock level's graph restores exactly once
|
|
1013
|
+
const packageCheckedLevels = new Set(); // each level's package-lock rows consumed exactly once
|
|
1014
|
+
// reviewer-455ba15 fix 1 — partition-not-rerun: run the engine's chain-walking
|
|
1015
|
+
// package restore as FEW times as the API allows and hand out each level's
|
|
1016
|
+
// rows exactly once. One invocation covers a scope's entire ancestor chain;
|
|
1017
|
+
// rows are stashed so no level is ever REPORTED twice and no already-walked
|
|
1018
|
+
// level triggers a re-invocation. RESIDUAL (pending WS1's exact-levels API,
|
|
1019
|
+
// relayed as a want): a descendant owning its own lock necessarily re-walks
|
|
1020
|
+
// its ancestors inside the engine — present/valid ancestor artifacts re-verify
|
|
1021
|
+
// with local reads only, but a FAILED ancestor fetch may retry once per
|
|
1022
|
+
// lock-owning descendant. The exact-once reporting contract holds.
|
|
1023
|
+
const pendingPkgRows = new Map(); // level(resolved) → rows not yet consumed
|
|
1024
|
+
const packageRowsFor = (scope, levels) => {
|
|
1025
|
+
const wanted = levels.map((l) => resolve(l)).filter((l) => !packageCheckedLevels.has(l));
|
|
1026
|
+
if (!wanted.length) return [];
|
|
1027
|
+
if (wanted.some((l) => !pendingPkgRows.has(l))) {
|
|
1028
|
+
// One restore invocation covers scope's whole chain; stash every level's
|
|
1029
|
+
// rows so later scopes never re-invoke for already-walked levels.
|
|
1030
|
+
for (const [lvl, rows] of partitionedPackageRestore(scope)) {
|
|
1031
|
+
if (!pendingPkgRows.has(lvl)) pendingPkgRows.set(lvl, rows);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const out = [];
|
|
1035
|
+
for (const l of wanted) {
|
|
1036
|
+
packageCheckedLevels.add(l);
|
|
1037
|
+
out.push(...(pendingPkgRows.get(l) || []));
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
};
|
|
1041
|
+
for (const scope of scopes) {
|
|
1042
|
+
// Boundary: full ancestor chain (current-chain semantics). Descendants: their
|
|
1043
|
+
// own level only — every level between boundary and descendant is either the
|
|
1044
|
+
// boundary chain or an earlier discovered scope, so no level repeats and no
|
|
1045
|
+
// failed ancestor restore is retried (or hidden) per descendant.
|
|
1046
|
+
const chainLevels = scope === boundary ? undefined : [scope];
|
|
1047
|
+
const report = restoreCapabilities(scope, chainLevels ? { levels: chainLevels.filter((l) => !restoredLevels.has(resolve(l))) } : undefined)
|
|
1048
|
+
.filter((r) => !restoredLevels.has(resolve(r.level)));
|
|
1049
|
+
// v2 package locks: every lock level this scope covers (the boundary covers
|
|
1050
|
+
// its whole ancestor chain), each restored/verified exactly once.
|
|
1051
|
+
report.push(...packageRowsFor(scope, scope === boundary ? lockLevelsUp(boundary) : [scope]));
|
|
1052
|
+
for (const r of report) {
|
|
1053
|
+
reportedAny = true;
|
|
1054
|
+
const what = r.package ? `package ${r.id ?? "(lock)"}` : r.id;
|
|
1055
|
+
if (r.status === "present") note(`ok ${what} [${shortPath(r.level)}]`);
|
|
1056
|
+
else if (r.status === "restored") note(`restored ${what} → ${shortPath(r.dir)} [${shortPath(r.level)}]`);
|
|
1057
|
+
else if (r.status === "legacy") note(`LEGACY ${shortPath(join(r.level, OATS_LOCK_FILE))}: ${r.reason}`);
|
|
1058
|
+
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)}]`); }
|
|
1059
|
+
else { failures.push({ scope: r.level, id: what, reason: r.reason, code: r.code }); note(`FAILED ${what} ${r.reason} [${shortPath(r.level)}]`); }
|
|
1060
|
+
}
|
|
1061
|
+
if (scope === boundary) for (const cfg of configChain(boundary)) restoredLevels.add(resolve(cfg._level));
|
|
1062
|
+
for (const r of report) restoredLevels.add(resolve(r.level));
|
|
1063
|
+
restoredLevels.add(resolve(scope));
|
|
1064
|
+
// Validate: every config-referenced installed capability supplied by a visible locked package/capability lock.
|
|
1065
|
+
if (existsSync(join(scope, "oats-config.yaml"))) {
|
|
1066
|
+
try {
|
|
1067
|
+
const supplied = lockedPackageCapabilities(scope);
|
|
1068
|
+
const capLocks = readCapabilityLocks(scope);
|
|
1069
|
+
for (const cfg of configChain(scope)) {
|
|
1070
|
+
if (resolve(cfg._level) !== resolve(scope)) continue;
|
|
1071
|
+
for (const [slot, entry] of Object.entries(cfg.capabilities?.layers || {})) {
|
|
1072
|
+
if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(entry.capability) && !capLocks[entry.capability]) {
|
|
1073
|
+
failures.push({ scope, id: entry.capability, reason: `referenced by capabilities.layers.${slot} but supplied by no visible locked package` });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
for (const [id, entry] of Object.entries(cfg.capabilities?.additive || {})) {
|
|
1077
|
+
if (entry && typeof entry === "object" && entry.from === "installed" && !supplied.has(id) && !capLocks[id]) {
|
|
1078
|
+
failures.push({ scope, id, reason: "referenced in config but supplied by no visible locked package" });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
} catch (e) { failures.push({ scope, id: "(config)", reason: e.message }); }
|
|
1083
|
+
}
|
|
1084
|
+
scopeReports.push({ scope, artifacts: report.map(artifactJson) });
|
|
1085
|
+
}
|
|
1086
|
+
if (!reportedAny && scopes.length === 1) note("Nothing to restore — no locked capabilities or packages found in the boundary.");
|
|
1087
|
+
const requirements = requirementsGate(scopes);
|
|
1088
|
+
for (const q of requirements) {
|
|
1089
|
+
if (q.outcome === "failed") failures.push({ scope: boundary, id: `requirement ${q.command}`, reason: q.reason || "consented install failed" });
|
|
1090
|
+
}
|
|
1091
|
+
emitReconcileResult({
|
|
1092
|
+
boundary, boundaryKind: declaresTeamHere ? "team" : "recursive",
|
|
1093
|
+
scopes: scopeReports, requirements, failures,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Host-requirement consent gate. Requirements are considered only for capabilities
|
|
1098
|
+
* activated somewhere in the reconciled scopes, deduplicated by command. Interactive
|
|
1099
|
+
* runs prompt per requirement with the exact command/source/version and state scope;
|
|
1100
|
+
* non-interactive runs NEVER install by default — automation names each accepted
|
|
1101
|
+
* requirement via --accept-requirement <command>; --no-requirements skips entirely.
|
|
1102
|
+
* Skipping leaves an actionable doctor warning (doctor recomputes missing commands).
|
|
1103
|
+
* Returns structured entries with a stable outcome enum:
|
|
1104
|
+
* "installed" consented install ran and the command verified on PATH
|
|
1105
|
+
* "failed" consented install errored or PATH verification missed (→ reconcile failure)
|
|
1106
|
+
* "consent-required" not explicitly accepted — nothing installed
|
|
1107
|
+
* "skipped" --no-requirements, or no safe installer for this host
|
|
1108
|
+
* JSON plan data equals the human prompt plan (argv/source/version/scope/requestedBy;
|
|
1109
|
+
* never shell text). In JSON mode all prose goes to stderr. */
|
|
1110
|
+
function requirementsGate(scopes) {
|
|
1111
|
+
// Malformed repeatable flags are usage errors regardless of which branch
|
|
1112
|
+
// runs — validate up front so --no-requirements cannot mask them.
|
|
1113
|
+
const accepted = new Set(flagAll("accept-requirement"));
|
|
1114
|
+
// Explicitly named requirements bypass runtime scoping, so the remediation
|
|
1115
|
+
// command a failed spawn prints actually installs something.
|
|
1116
|
+
const missing = aggregateMissingRequirements(scopes, { accepted });
|
|
1117
|
+
if (!missing.length) return [];
|
|
1118
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
1119
|
+
const entryOf = (req, outcome, extra = {}) => ({
|
|
1120
|
+
command: req.command, kind: req.kind || "host-command",
|
|
1121
|
+
runtime: req.runtime || null, package: req.package || null, why: req.why || null,
|
|
1122
|
+
// `steps` is the ORDERED sequence runRequirementInstall actually executes;
|
|
1123
|
+
// `argv` is only its last command. Serializing argv alone hid a
|
|
1124
|
+
// `claude plugin marketplace add <source>` — a lower-trust source
|
|
1125
|
+
// registration — from every client consenting through the JSON API
|
|
1126
|
+
// (reviewer-final0130bc8). Always present, exactly as doctor renders it, so
|
|
1127
|
+
// single- and multi-step plans have one shape.
|
|
1128
|
+
plan: req.plan && !req.plan.unavailable
|
|
1129
|
+
? { 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 }
|
|
1130
|
+
: null,
|
|
1131
|
+
requestedBy: req.requestedBy, docs: req.docs || null, outcome, ...extra,
|
|
1132
|
+
});
|
|
1133
|
+
// Fail-closed identity/conflict policy (E_REQUIREMENT_POLICY): invalid command
|
|
1134
|
+
// tokens and same-command conflicting plans are NEVER consentable or installable
|
|
1135
|
+
// — they fail reconciliation deterministically with provenance, even under
|
|
1136
|
+
// --no-requirements (skipping consent does not skip safety validation).
|
|
1137
|
+
const policyEntries = [];
|
|
1138
|
+
for (const req of missing) {
|
|
1139
|
+
if (req.invalid) {
|
|
1140
|
+
note(` INVALID requirement command ${JSON.stringify(req.command)} — ${req.invalid} (requested by: ${req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ")})`);
|
|
1141
|
+
policyEntries.push(entryOf(req, "failed", { reason: req.invalid, code: "E_REQUIREMENT_POLICY" }));
|
|
1142
|
+
} else if (req.conflict) {
|
|
1143
|
+
note(` CONFLICT for command "${req.command}": capabilities request non-identical install plans — no install is offered`);
|
|
1144
|
+
// Show the FULL sequence: two capabilities can agree on the final install
|
|
1145
|
+
// command while registering different third-party sources before it.
|
|
1146
|
+
for (const p of req.conflict.plans) {
|
|
1147
|
+
const shown = p.steps?.length ? p.steps.map((a) => a.join(" ")).join(" && ") : (p.argv ? p.argv.join(" ") : p.unavailable || "no plan");
|
|
1148
|
+
note(` ${p.capability} [${shortPath(p.scope)}]: ${shown}`);
|
|
1149
|
+
}
|
|
1150
|
+
policyEntries.push(entryOf(req, "failed", { reason: "conflicting install plans for the same command", code: "E_REQUIREMENT_POLICY", conflict: req.conflict }));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const consentable = missing.filter((req) => !req.invalid && !req.conflict);
|
|
1154
|
+
if (args.includes("--no-requirements")) return [...policyEntries, ...consentable.map((req) => entryOf(req, "skipped", { reason: "--no-requirements" }))];
|
|
1155
|
+
const interactive = !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY;
|
|
1156
|
+
const out = [...policyEntries];
|
|
1157
|
+
if (consentable.length) note(`\nMissing requirements for active capabilities (${consentable.length}):`);
|
|
1158
|
+
for (const req of consentable) {
|
|
1159
|
+
const requesters = req.requestedBy.map((r) => `${r.capability} [${shortPath(r.scope)}]`).join(", ");
|
|
1160
|
+
note(` ${req.command} — ${req.why || "required"} (requested by: ${requesters})`);
|
|
1161
|
+
const plan = req.plan;
|
|
1162
|
+
if (!plan || plan.unavailable) {
|
|
1163
|
+
note(` no safe installer: ${plan?.unavailable || "no recipe"}${req.docs ? ` — install docs: ${req.docs}` : ""}`);
|
|
1164
|
+
out.push(entryOf(req, "skipped", { reason: plan?.unavailable || "no safe installer" }));
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
// Show EVERY step: installing a Claude plugin also registers a third-party
|
|
1168
|
+
// marketplace, and consent to that must be visible, not implied.
|
|
1169
|
+
const shown = (plan.steps?.length ? plan.steps : [plan.argv]).map((a) => a.join(" ")).join(" && ");
|
|
1170
|
+
note(` installer: ${shown} (source: ${plan.source}${plan.version ? `, version ${plan.version}` : ""}; ${plan.scope})`);
|
|
1171
|
+
let consent = accepted.has(req.command);
|
|
1172
|
+
if (!consent && interactive) {
|
|
1173
|
+
process.stdout.write(` Run this install now? [y/N] `);
|
|
1174
|
+
const buf = Buffer.alloc(64);
|
|
1175
|
+
let answer = "";
|
|
1176
|
+
try { answer = buf.toString("utf8", 0, readSync(process.stdin.fd, buf, 0, 64)).trim().toLowerCase(); } catch { /* EOF */ }
|
|
1177
|
+
consent = answer === "y" || answer === "yes";
|
|
1178
|
+
}
|
|
1179
|
+
if (!consent) {
|
|
1180
|
+
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"}`);
|
|
1181
|
+
out.push(entryOf(req, "consent-required"));
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
const r = runRequirementInstall(plan, JSON_MODE ? { stdio: ["ignore", 2, 2] } : {});
|
|
1186
|
+
// A runtime package is verified in its runtime's package list, never on
|
|
1187
|
+
// PATH — saying "on PATH" for one would be false either way it lands.
|
|
1188
|
+
const where = req.kind === "runtime-package" ? `installed for ${req.runtime}` : "on PATH";
|
|
1189
|
+
if (r.onPath) { note(` installed — ${req.command} verified ${where}`); out.push(entryOf(req, "installed", { onPath: true })); }
|
|
1190
|
+
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}` })); }
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
note(` FAILED: ${e.message}`);
|
|
1193
|
+
out.push(entryOf(req, "failed", { onPath: false, reason: e.message }));
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
note("Requirement consent is separate from capability trust — installing a binary does not activate or approve any capability.");
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** oats trust <capability> | oats trust <package> --all-capabilities */
|
|
1201
|
+
function trust() {
|
|
1202
|
+
const id = args[1];
|
|
1203
|
+
if (!id || id.startsWith("--")) { cmdFail("E_USAGE", "usage: oats trust <capability> [--dir <dir>] | oats trust <package> --all-capabilities [--dir <dir>]"); return; }
|
|
1204
|
+
const dir = dirFlag();
|
|
1205
|
+
const all = args.includes("--all-capabilities");
|
|
1206
|
+
// Package-backed approval path (per-capability, or explicit bulk on a package id).
|
|
1207
|
+
let pkgs, locks;
|
|
1208
|
+
try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
1209
|
+
// findLast: the listing runs outermost → innermost, and an identity resolves
|
|
1210
|
+
// to the CLOSEST scope that locks it — the same rule the merged lock maps use.
|
|
1211
|
+
const backing = all ? pkgs.findLast((p) => p.package === id) : pkgs.findLast((p) => p.capabilities.some((c) => c.id === id));
|
|
1212
|
+
if (backing) {
|
|
1213
|
+
// Approval is per capability unless --all-capabilities is explicit. Print
|
|
1214
|
+
// exactly the authority this invocation will persist, before persisting it;
|
|
1215
|
+
// JSON mode uses stderr so stdout remains one machine envelope.
|
|
1216
|
+
const requested = all ? backing.capabilities : backing.capabilities.filter((c) => c.id === id);
|
|
1217
|
+
const out = JSON_MODE ? console.error : console.log;
|
|
1218
|
+
out(`Package ${backing.package}@${backing.version} ${all ? "full" : "requested"} executable surface:`);
|
|
1219
|
+
for (const c of requested) {
|
|
1220
|
+
const cmds = Object.keys(c.manifest.commands || {});
|
|
1221
|
+
const hooks = Object.keys(c.manifest.hooks || {});
|
|
1222
|
+
const environment = c.manifest.environment || [];
|
|
1223
|
+
out(` ${c.id}: commands [${cmds.join(", ") || "none"}], hooks [${hooks.join(", ") || "none"}], launch environment [${environment.join(", ") || "none"}]`);
|
|
1224
|
+
}
|
|
1225
|
+
// FAIL CLOSED BEFORE APPROVING. The engine binds approval to the artifact's
|
|
1226
|
+
// integrity, but integrity alone cannot see a `.oats-installation.json` that
|
|
1227
|
+
// claims a different origin than the lock — and approving a capability whose
|
|
1228
|
+
// own provenance is disputed is exactly the thing trust must not do.
|
|
1229
|
+
const trustRows = levelRows(locks, backing.level);
|
|
1230
|
+
const disputed = backing.capabilities
|
|
1231
|
+
.filter((c) => all || c.id === id)
|
|
1232
|
+
.map((c) => capabilityHealth(backing.level, c, trustRows.capabilities[c.id], trustRows.packages[backing.package]))
|
|
1233
|
+
.filter((h) => h.status !== "ok" && h.status !== "untrusted");
|
|
1234
|
+
if (disputed.length) { cmdFail(disputed[0].code || "invalid-lock", `refusing to trust: ${disputed.map((h) => h.detail).join("; ")}`); return; }
|
|
1235
|
+
let r;
|
|
1236
|
+
try { r = approveCapability(dir, id, { allCapabilities: all }); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
1237
|
+
// Approval binds to each capability's exact MATERIALIZED ARTIFACT, so the
|
|
1238
|
+
// integrity reported is per capability — there is no package-level digest
|
|
1239
|
+
// to approve against and none to print.
|
|
1240
|
+
const approvedIntegrity = {};
|
|
1241
|
+
for (const c of backing.capabilities) if (r.approved.includes(c.id)) approvedIntegrity[c.id] = c.integrity || null;
|
|
1242
|
+
if (JSON_MODE) {
|
|
1243
|
+
// The engine's own surface, not a re-derivation: what it approved and what
|
|
1244
|
+
// it saw must be the same object.
|
|
1245
|
+
jsonOk({ package: r.package, level: r.level, approved: r.approved, skipped: r.skipped, approvedIntegrity, executableSurface: r.executableSurface, file: r.file });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
for (const c of r.approved) console.log(`Trusted executable surface for ${c} (from package ${r.package}, artifact ${approvedIntegrity[c] || "?"}).`);
|
|
1249
|
+
if (r.skipped.length) console.log(`No executable surface (artifact integrity suffices, no approval needed): ${r.skipped.join(", ")}`);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (all) { cmdFail("unknown-capability", `no installed package "${id}" — --all-capabilities takes a package identity`); return; }
|
|
1253
|
+
// Legacy standalone capability path.
|
|
1254
|
+
const manifest = capabilityManifest(id, dir);
|
|
1255
|
+
if (!manifest) { cmdFail("unknown-capability", `unknown capability "${id}"`); return; }
|
|
1256
|
+
const lock = readCapabilityLocks(dir)[manifest.capability];
|
|
1257
|
+
if (!lock) { cmdFail("invalid-lock", `${manifest.capability} is not locked in ${OATS_LOCK_FILE}`); return; }
|
|
1258
|
+
const integrity = capabilityIntegrity(manifest._dir);
|
|
1259
|
+
if (integrity !== lock.integrity) { cmdFail("integrity-drift", `integrity changed (${lock.integrity} → ${integrity}); reacquire explicitly before trusting`); return; }
|
|
1260
|
+
const { _file, ...clean } = lock;
|
|
1261
|
+
if (manifest.environment?.length) (JSON_MODE ? console.error : console.log)(`Requested launch environment: ${manifest.environment.join(", ")}`);
|
|
1262
|
+
try { writeCapabilityLock(dirname(_file), manifest.capability, { ...clean, trustedExecutables: true }); }
|
|
1263
|
+
catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
1264
|
+
if (JSON_MODE) { jsonOk({ capability: manifest.capability, integrity, legacy: true, environment: [...(manifest.environment || [])] }); return; }
|
|
1265
|
+
console.log(`Trusted executable surface for ${manifest.capability} at ${integrity}.`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ---------- package config profiles (oats init --package / oats config diff) ----------
|
|
1269
|
+
/** Collect every value of a repeatable flag (e.g. --accept-requirement a --accept-requirement b).
|
|
1270
|
+
* A missing or flag-shaped value is a usage error — one E_USAGE envelope in JSON mode. */
|
|
1271
|
+
function flagAll(name) {
|
|
1272
|
+
const out = [];
|
|
1273
|
+
for (let i = 0; i < args.length; i++) {
|
|
1274
|
+
if (args[i] !== `--${name}`) continue;
|
|
1275
|
+
if (args[i + 1] && !args[i + 1].startsWith("--")) out.push(args[i + 1]);
|
|
1276
|
+
else (JSON_MODE ? jsonFail("E_USAGE", `--${name} needs a value`) : die(`--${name} needs a value`));
|
|
1277
|
+
}
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Dependency-closure PROVIDER RECORDS for config-template validation.
|
|
1282
|
+
*
|
|
1283
|
+
* The flat model made this much smaller than its package-root ancestor: the
|
|
1284
|
+
* engine's lock reader walks raw lock-owning scopes rather than the config
|
|
1285
|
+
* chain, so a configless scope being initialized now sees its OWN lock without
|
|
1286
|
+
* the manual merge this used to need, and capability rows carry the provider
|
|
1287
|
+
* back-reference directly instead of package rows carrying capability lists.
|
|
1288
|
+
*
|
|
1289
|
+
* `staged` supplies the capabilities projected by THIS run's acquisition, which
|
|
1290
|
+
* are not locked yet when the pre-commit gate validates the template.
|
|
1291
|
+
* Returns { capabilities: Map<capabilityId, capabilityManifest|null> } — null
|
|
1292
|
+
* means lock-visible but not materialized, so layer agreement is unverifiable.
|
|
1293
|
+
*/
|
|
1294
|
+
function dependencyClosureProviders(rootId, dir, staged = []) {
|
|
1295
|
+
const capabilities = new Map();
|
|
1296
|
+
let locks = { packages: {}, capabilities: {} };
|
|
1297
|
+
try { locks = readPackageLocks(dir); } catch { /* invalid lock surfaces at acquire */ }
|
|
1298
|
+
|
|
1299
|
+
const closure = new Set();
|
|
1300
|
+
const visit = (pkgId) => {
|
|
1301
|
+
if (!pkgId || closure.has(pkgId) || !Object.hasOwn(locks.packages, pkgId)) return;
|
|
1302
|
+
closure.add(pkgId);
|
|
1303
|
+
for (const dep of locks.packages[pkgId].dependencies || []) visit(dep);
|
|
1304
|
+
};
|
|
1305
|
+
visit(rootId);
|
|
1306
|
+
|
|
1307
|
+
for (const [capId, row] of Object.entries(locks.capabilities)) {
|
|
1308
|
+
if (!closure.has(row.package)) continue;
|
|
1309
|
+
let manifest = null;
|
|
1310
|
+
try {
|
|
1311
|
+
const artifact = installedCapabilityDir(row._level, capId);
|
|
1312
|
+
if (existsSync(join(artifact, "oats.json"))) manifest = JSON.parse(readFileSync(join(artifact, "oats.json"), "utf8"));
|
|
1313
|
+
} catch { /* unreadable artifact is a doctor problem, not a validation input */ }
|
|
1314
|
+
capabilities.set(capId, manifest);
|
|
1315
|
+
}
|
|
1316
|
+
// Same-run acquisition visibility: the root's own exports exist only in
|
|
1317
|
+
// staging while the gate runs, and a template that binds them must validate.
|
|
1318
|
+
// Preview rows carry the declared `layer` (null when none) — the minimum the
|
|
1319
|
+
// layer-agreement check needs — so a staged capability is represented by that
|
|
1320
|
+
// one field rather than a manifest the engine deliberately does not expose.
|
|
1321
|
+
for (const c of staged) capabilities.set(c.capability, c.manifest ?? { layer: c.layer ?? null });
|
|
1322
|
+
return { capabilities };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Adopt a template from a package ALREADY locked at this scope: read its exact
|
|
1326
|
+
* locked templates, validate, then write config + base + metadata under the run
|
|
1327
|
+
* journal. Nothing is fetched beyond the locked source, and nothing is
|
|
1328
|
+
* re-acquired — the lock is already the truth about what is installed here. */
|
|
1329
|
+
function initPackageFromLock(packageId, dir, file, lockedRoot, configFlag, bail, note) {
|
|
1330
|
+
let locked, chosen;
|
|
1331
|
+
try { locked = readLockedConfigTemplates(dir, packageId); }
|
|
1332
|
+
catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); return; }
|
|
1333
|
+
try { chosen = selectConfigTemplate(locked.templates, configFlag, packageId); }
|
|
1334
|
+
catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); return; }
|
|
1335
|
+
|
|
1336
|
+
const errors = validateConfigTemplate(chosen, packageId, {
|
|
1337
|
+
dependencyProviders: dependencyClosureProviders(packageId, dir).capabilities,
|
|
1338
|
+
});
|
|
1339
|
+
if (errors.length) bail("E_TEMPLATE_INVALID", `config template "${chosen.template}" of package ${packageId} failed validation:\n - ${errors.join("\n - ")}`);
|
|
1340
|
+
|
|
1341
|
+
note(`Package ${packageId}${locked.version ? `@${locked.version}` : ""} is already locked here — adopting its config template "${chosen.template}" without re-acquiring.`);
|
|
1342
|
+
|
|
1343
|
+
let journal;
|
|
1344
|
+
try { journal = beginRunJournal(dir); }
|
|
1345
|
+
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
|
|
1346
|
+
let adoption;
|
|
1347
|
+
try {
|
|
1348
|
+
adoption = writeAdoptedTemplate(dir, file, {
|
|
1349
|
+
package: packageId, template: chosen,
|
|
1350
|
+
root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
|
|
1351
|
+
});
|
|
1352
|
+
journal.finalize();
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
const report = journal.rollback();
|
|
1355
|
+
bail("E_ADOPT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const locks = readPackageLocks(dir);
|
|
1360
|
+
const capabilities = Object.entries(locks.capabilities).filter(([, c]) => c.package === packageId).map(([id]) => id);
|
|
1361
|
+
note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${packageId}:${chosen.template}`);
|
|
1362
|
+
if (JSON_MODE) {
|
|
1363
|
+
jsonOk({
|
|
1364
|
+
package: packageId, version: locked.version || null, commit: locked.commit || null,
|
|
1365
|
+
template: chosen.template, adopted: true, file, capabilities,
|
|
1366
|
+
adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
|
|
1367
|
+
contentIntegrity: chosen.contentIntegrity,
|
|
1368
|
+
lockFile: lockedRoot._file || join(dir, OATS_LOCK_FILE), lockedPackages: Object.keys(locks.packages),
|
|
1369
|
+
});
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
offerTmuxMouseScrolling();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** oats init --package <source> [--config <name>]: acquire a package AND adopt
|
|
1376
|
+
* one of its config templates as this scope's local config.
|
|
1377
|
+
*
|
|
1378
|
+
* This command is adoption, not an install alias: `oats install <package>`
|
|
1379
|
+
* installs capabilities and applies no template, while this one always adopts
|
|
1380
|
+
* exactly one — the named template, else the single marked default, else the
|
|
1381
|
+
* only one. Several unmarked templates are E_TEMPLATE_AMBIGUOUS and a package
|
|
1382
|
+
* with none is E_NO_TEMPLATES; both refuse inside the pre-commit gate, so the
|
|
1383
|
+
* scope is never touched.
|
|
1384
|
+
*
|
|
1385
|
+
* Transaction shape: the outer journal opens BEFORE acquisition, so its
|
|
1386
|
+
* snapshot is the pre-command state. A gate refusal or acquire failure rolls it
|
|
1387
|
+
* back (the engine is zero-mutation there, so this mainly closes the backup); a
|
|
1388
|
+
* failure while writing the adoption files rolls back the lock, the capability
|
|
1389
|
+
* store, the ignore file, the config and the adopted base together — the
|
|
1390
|
+
* newly acquired state disappears and every pre-existing byte returns.
|
|
1391
|
+
* finalize() runs only after every adoption write has succeeded.
|
|
1392
|
+
*
|
|
1393
|
+
* JSON mode: one compact envelope. CLI codes E_TEMPLATE_INVALID /
|
|
1394
|
+
* E_TEMPLATE_AMBIGUOUS / E_TEMPLATE_NOT_FOUND / E_NO_TEMPLATES; engine codes
|
|
1395
|
+
* pass through verbatim. Fully noninteractive. */
|
|
1396
|
+
function initPackage(src, dir, file) {
|
|
1397
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
1398
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
1399
|
+
const configFlag = flag("config");
|
|
1400
|
+
if (configFlag === true) bail("E_USAGE", "--config needs a template name");
|
|
1401
|
+
|
|
1402
|
+
let chosen = null; // the selected+validated template descriptor
|
|
1403
|
+
let rootRecord = null; // the acquired root package row
|
|
1404
|
+
let projected = []; // projected capability rows
|
|
1405
|
+
|
|
1406
|
+
/** The pre-commit gate. Everything that can refuse refuses HERE, while the
|
|
1407
|
+
* scope is still untouched. It THROWS rather than exiting: the process-exit
|
|
1408
|
+
* path would strand the journal's backup, and the engine propagates a gate
|
|
1409
|
+
* throw unchanged with nothing mutated. */
|
|
1410
|
+
const assertCommittable = (preview) => {
|
|
1411
|
+
rootRecord = preview.packages.find((p) => p.package === preview.root) || null;
|
|
1412
|
+
projected = preview.capabilities || [];
|
|
1413
|
+
const templates = (preview.configTemplates || []).filter((t) => t.package === preview.root);
|
|
1414
|
+
|
|
1415
|
+
note(`Package ${preview.root}${rootRecord?.version ? `@${rootRecord.version}` : ""} — installs ${projected.length} capability(ies): ${projected.map((c) => c.capability).join(", ") || "(none)"}`);
|
|
1416
|
+
const executable = projected.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length);
|
|
1417
|
+
if (executable.length) note(` executable surfaces needing separate approval: ${executable.map((c) => c.capability).join(", ")} (\`oats trust <id>\`)`);
|
|
1418
|
+
|
|
1419
|
+
chosen = selectConfigTemplate(templates, configFlag, preview.root); // throws typed codes
|
|
1420
|
+
// Every check now refuses PRE-COMMIT, layer agreement included: preview
|
|
1421
|
+
// capability rows carry the declared layer, so a template binding a slot to
|
|
1422
|
+
// one of the package's own staged capabilities is validated here, with the
|
|
1423
|
+
// scope untouched and no rollback needed.
|
|
1424
|
+
const errors = validateConfigTemplate(chosen, preview.root, {
|
|
1425
|
+
dependencyProviders: dependencyClosureProviders(preview.root, dir, projected).capabilities,
|
|
1426
|
+
});
|
|
1427
|
+
if (errors.length) {
|
|
1428
|
+
const e = new Error(`config template "${chosen.template}" of package ${preview.root} failed validation:\n - ${errors.join("\n - ")}`);
|
|
1429
|
+
e.code = "E_TEMPLATE_INVALID";
|
|
1430
|
+
throw e;
|
|
1431
|
+
}
|
|
1432
|
+
note(`Config template "${chosen.template}"${chosen.description ? `: ${chosen.description}` : ""} — validated (${chosen.contentIntegrity})`);
|
|
1433
|
+
note(` it becomes YOUR local ${shortPath(file)}: every copied setting is editable, and package updates never rewrite it.`);
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
// Opened BEFORE acquisition: a snapshot taken afterwards would record the new
|
|
1437
|
+
// lock, artifacts and ignore bytes as the "pre-existing" state and could
|
|
1438
|
+
// never undo them.
|
|
1439
|
+
// An id already locked at this scope is adopted from the LOCK, not
|
|
1440
|
+
// re-acquired: its exact source/commit is already pinned, the capabilities are
|
|
1441
|
+
// already materialized, and going to the network (or the catalog) to re-fetch
|
|
1442
|
+
// what the lock already names would be a different package than the one
|
|
1443
|
+
// installed here. This is the `oats init --package <id>` half of the documented
|
|
1444
|
+
// <id|path|git-url> form.
|
|
1445
|
+
let lockedRoot = null;
|
|
1446
|
+
try { lockedRoot = readPackageLocks(dir).packages[src] || null; }
|
|
1447
|
+
catch { /* an invalid lock surfaces with its own typed code below */ }
|
|
1448
|
+
if (lockedRoot) { initPackageFromLock(src, dir, file, lockedRoot, configFlag, bail, note); return; }
|
|
1449
|
+
|
|
1450
|
+
// Constructed inside its own guard: a journal that cannot be built (a symlink
|
|
1451
|
+
// component, an unreadable snapshot, a backup that would land inside the
|
|
1452
|
+
// scope) must still leave the command with exactly one JSON envelope. There
|
|
1453
|
+
// is nothing to roll back yet, so its typed code goes straight to bail.
|
|
1454
|
+
let journal;
|
|
1455
|
+
try { journal = beginRunJournal(dir); }
|
|
1456
|
+
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
|
|
1457
|
+
|
|
1458
|
+
/** Undo the run, then report. `code` is the engine's verbatim code for
|
|
1459
|
+
* acquisition failures and a stable CLI code for our own write failures — a
|
|
1460
|
+
* raw errno like ENOTDIR is not a contract automation can branch on. */
|
|
1461
|
+
const abort = (e, code) => {
|
|
1462
|
+
const report = journal.rollback();
|
|
1463
|
+
const detail = code === "E_ADOPT_FAILED" ? `adopting the config template failed after the package was installed: ${e.message}` : e.message;
|
|
1464
|
+
bail(code || e.code || "E_INIT_FAILED", report.complete ? detail : `${detail} — ${report.summary}`);
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
let acq;
|
|
1468
|
+
try { acq = acquirePackage(dir, src, { assertCommittable }); }
|
|
1469
|
+
catch (e) { abort(e); return; }
|
|
1470
|
+
|
|
1471
|
+
note(`Acquired + locked: ${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")} → ${shortPath(acq.lockFile)}`);
|
|
1472
|
+
const capabilities = acq.capabilities.map((c) => c.capability);
|
|
1473
|
+
|
|
1474
|
+
// DEFENCE IN DEPTH, not the primary check. The gate above already validated
|
|
1475
|
+
// every binding against the preview's declared layers; this re-checks them
|
|
1476
|
+
// against the manifests actually written to disk, so a projection that
|
|
1477
|
+
// disagreed with its own preview cannot leave a broken config behind. It
|
|
1478
|
+
// should never fire — and if it does, the journal restores the scope
|
|
1479
|
+
// completely, so nothing of the run survives.
|
|
1480
|
+
const materialized = new Map();
|
|
1481
|
+
for (const c of acq.capabilities) {
|
|
1482
|
+
let manifest = null;
|
|
1483
|
+
try { manifest = JSON.parse(readFileSync(join(installedCapabilityDir(dir, c.capability), "oats.json"), "utf8")); }
|
|
1484
|
+
catch { /* unreadable artifact is reported by doctor; leave it unverifiable */ }
|
|
1485
|
+
materialized.set(c.capability, manifest);
|
|
1486
|
+
}
|
|
1487
|
+
for (const [id, m] of dependencyClosureProviders(acq.root, dir).capabilities) if (!materialized.has(id)) materialized.set(id, m);
|
|
1488
|
+
const lateErrors = validateConfigTemplate(chosen, acq.root, { dependencyProviders: materialized });
|
|
1489
|
+
if (lateErrors.length) {
|
|
1490
|
+
const e = new Error(`config template "${chosen.template}" of package ${acq.root} failed validation:\n - ${lateErrors.join("\n - ")}`);
|
|
1491
|
+
e.code = "E_TEMPLATE_INVALID";
|
|
1492
|
+
abort(e);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
let adoption;
|
|
1497
|
+
try {
|
|
1498
|
+
adoption = writeAdoptedTemplate(dir, file, { package: acq.root, template: chosen, root: rootRecord });
|
|
1499
|
+
journal.finalize();
|
|
1500
|
+
} catch (e) { abort(e, "E_ADOPT_FAILED"); return; }
|
|
1501
|
+
|
|
1502
|
+
note(`Created ${shortPath(file)} (${levelOf(dir)} level) from config template ${acq.root}:${chosen.template}`);
|
|
1503
|
+
note(`Recorded the adopted base at ${shortPath(adoption.baseFile)} — commit it; \`oats config diff\` and \`oats config sync\` compare against it.`);
|
|
1504
|
+
if (JSON_MODE) {
|
|
1505
|
+
jsonOk({
|
|
1506
|
+
package: acq.root, version: rootRecord?.version || null, commit: rootRecord?.commit || null,
|
|
1507
|
+
template: chosen.template, adopted: true, file, capabilities,
|
|
1508
|
+
adoptedBase: adoption.baseFile, adoptionMetadata: adoption.metadataFile,
|
|
1509
|
+
contentIntegrity: chosen.contentIntegrity,
|
|
1510
|
+
lockFile: acq.lockFile, lockedPackages: acq.installed.map((p) => p.package),
|
|
1511
|
+
});
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
offerTmuxMouseScrolling();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** `oats config <diff|sync|adopt>` — the guided three-way template lane.
|
|
1518
|
+
*
|
|
1519
|
+
* All three share one comparison: the recorded adopted base, the current local
|
|
1520
|
+
* oats-config.yaml, and the selected template read from the CURRENT EXACT LOCK.
|
|
1521
|
+
* Only `sync` and `adopt` mutate, and both present the complete plan first.
|
|
1522
|
+
*/
|
|
1523
|
+
function configCmd() {
|
|
1524
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
1525
|
+
const sub = args[1];
|
|
1526
|
+
if (!["diff", "sync", "adopt"].includes(sub)) {
|
|
1527
|
+
bail("E_USAGE", "usage: oats config <diff|sync|adopt> [--config <template>] [--dir <dir>] [--json]");
|
|
1528
|
+
}
|
|
1529
|
+
const dir = resolve(flag("dir") || process.cwd());
|
|
1530
|
+
const file = join(dir, "oats-config.yaml");
|
|
1531
|
+
if (!existsSync(file)) bail("E_NO_CONFIG", `no oats-config.yaml at ${shortPath(dir)} — adopt one with \`oats init --package <source> --config <name>\``);
|
|
1532
|
+
const localText = readFileSync(file, "utf8");
|
|
1533
|
+
|
|
1534
|
+
let adopted;
|
|
1535
|
+
try { adopted = readAdoptedTemplate(dir); }
|
|
1536
|
+
catch (e) { bail(e.code || "E_ADOPTION_INVALID", e.message); }
|
|
1537
|
+
|
|
1538
|
+
// `adopt` switches base; the others need an existing one.
|
|
1539
|
+
const adoptTarget = sub === "adopt" ? args[2] : undefined;
|
|
1540
|
+
if (sub === "adopt" && (!adoptTarget || adoptTarget.startsWith("--"))) {
|
|
1541
|
+
bail("E_USAGE", "usage: oats config adopt <package> [--config <template>] — the package must already be installed at this scope");
|
|
1542
|
+
}
|
|
1543
|
+
if (sub !== "adopt" && !adopted) {
|
|
1544
|
+
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>\``);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const packageId = sub === "adopt" ? adoptTarget : adopted.package;
|
|
1548
|
+
const templateFlag = flag("config");
|
|
1549
|
+
if (templateFlag === true) bail("E_USAGE", "--config needs a template name");
|
|
1550
|
+
const wanted = templateFlag || (sub === "adopt" ? undefined : adopted.template);
|
|
1551
|
+
|
|
1552
|
+
// Exact locked read — never the network-free guess, never a package root.
|
|
1553
|
+
let locked;
|
|
1554
|
+
try { locked = readLockedConfigTemplates(dir, packageId); }
|
|
1555
|
+
catch (e) { bail(e.code || "E_TEMPLATE_READ_FAILED", e.message); }
|
|
1556
|
+
let chosen;
|
|
1557
|
+
try { chosen = selectConfigTemplate(locked.templates, wanted, packageId); }
|
|
1558
|
+
catch (e) { bail(e.code || "E_TEMPLATE_AMBIGUOUS", e.message); }
|
|
1559
|
+
|
|
1560
|
+
// Switching base rebases the ONE local config against the new template.
|
|
1561
|
+
//
|
|
1562
|
+
// With no adopted base there is NO common ancestor, and pretending the local
|
|
1563
|
+
// file is one is the dangerous answer: a three-way merge whose base equals
|
|
1564
|
+
// local classifies every difference as upstream-only, so a first adopt would
|
|
1565
|
+
// silently replace a handcrafted config wholesale — no conflicts, no consent,
|
|
1566
|
+
// no preview of what was lost. An EMPTY base states the truth instead: every
|
|
1567
|
+
// existing local byte is local work, and anything the template also wants to
|
|
1568
|
+
// put there is a conflict the operator must resolve explicitly.
|
|
1569
|
+
const baseText = adopted ? adopted.baseText : "";
|
|
1570
|
+
const plan = planConfigMerge(baseText, localText, chosen.content);
|
|
1571
|
+
|
|
1572
|
+
const describe = (r) => ({
|
|
1573
|
+
id: r.id, kind: r.kind, recommended: r.recommended, digest: r.digest,
|
|
1574
|
+
startLine: r.local.start + 1, lines: r.local.end - r.local.start,
|
|
1575
|
+
base: r.base.text, local: r.local.text, package: r.template.text,
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
if (sub === "diff") {
|
|
1579
|
+
if (JSON_MODE) {
|
|
1580
|
+
jsonOk({
|
|
1581
|
+
package: packageId, template: chosen.template, version: locked.version || null, commit: locked.commit || null,
|
|
1582
|
+
file, adoptedBase: adopted?.baseFile || null, contentIntegrity: chosen.contentIntegrity,
|
|
1583
|
+
clean: plan.clean, counts: plan.counts, conflicts: plan.conflicts,
|
|
1584
|
+
regions: plan.regions.map(describe), planDigest: plan.planDigest,
|
|
1585
|
+
});
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
console.log(`oats config diff — ${shortPath(file)} vs ${packageId}:${chosen.template}${locked.version ? `@${locked.version}` : ""} (report only; nothing is written)\n`);
|
|
1589
|
+
if (!plan.regions.length) { console.log("No differences: your config, the adopted base, and the package template agree."); return; }
|
|
1590
|
+
for (const r of plan.regions) renderMergeRegion(r);
|
|
1591
|
+
console.log(`\n${plan.counts.upstream} upstream-only, ${plan.counts.local} local-only, ${plan.counts.conflict} conflict(s), ${plan.counts.agreed} already agreed.`);
|
|
1592
|
+
console.log(plan.clean
|
|
1593
|
+
? "Apply the upstream changes with `oats config sync` (local-only edits are kept)."
|
|
1594
|
+
: "`oats config sync` needs an explicit choice for each conflict — it will never pick one for you.");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ---- sync / reset / adopt: everything below MUTATES, so plan first ----
|
|
1599
|
+
|
|
1600
|
+
const decisions = {};
|
|
1601
|
+
for (const spec of flagAll("accept")) {
|
|
1602
|
+
const m = /^([^=]+)=(local|package)$/.exec(spec);
|
|
1603
|
+
if (!m) bail("E_USAGE", `--accept takes <regionId>=<local|package>, got "${spec}"`);
|
|
1604
|
+
decisions[m[1]] = m[2];
|
|
1605
|
+
}
|
|
1606
|
+
const assumeYes = args.includes("--yes");
|
|
1607
|
+
const isReset = args.includes("--reset");
|
|
1608
|
+
|
|
1609
|
+
// The recoverable backup survives a SUCCESSFUL run: the run journal is for
|
|
1610
|
+
// undoing failures, this is for the adopter who changes their mind.
|
|
1611
|
+
const backupFile = `${file}.bak`;
|
|
1612
|
+
|
|
1613
|
+
if (isReset) {
|
|
1614
|
+
// Reset previews everything it will destroy, then demands explicit consent.
|
|
1615
|
+
const lost = plan.regions.filter((r) => r.kind === "local" || r.kind === "conflict");
|
|
1616
|
+
if (JSON_MODE || !process.stdin.isTTY) {
|
|
1617
|
+
if (!assumeYes) {
|
|
1618
|
+
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`);
|
|
1619
|
+
}
|
|
1620
|
+
} else if (!assumeYes) {
|
|
1621
|
+
console.log(`This DISCARDS ${lost.length} local change region(s) in ${shortPath(file)}:\n`);
|
|
1622
|
+
for (const r of lost) renderMergeRegion(r);
|
|
1623
|
+
const answer = promptLine(`Type the word "discard" to replace it with ${packageId}:${chosen.template}: `);
|
|
1624
|
+
if (answer.trim() !== "discard") bail("E_RESET_NOT_CONFIRMED", "reset cancelled — nothing was changed");
|
|
1625
|
+
}
|
|
1626
|
+
const journal = openJournal(dir, bail);
|
|
1627
|
+
try {
|
|
1628
|
+
// NEVER copyFileSync onto a fixed backup path: it opens the destination
|
|
1629
|
+
// for write and therefore FOLLOWS it, so a pre-planted
|
|
1630
|
+
// `oats-config.yaml.bak` symlink would redirect this copy onto whatever it
|
|
1631
|
+
// points at. The atomic form replaces the entry itself.
|
|
1632
|
+
if (existsSync(file)) copyFileAtomic(file, backupFile);
|
|
1633
|
+
writeFileAtomic(file, chosen.content);
|
|
1634
|
+
recordAdoption(dir, file, packageId, chosen, locked, adopted);
|
|
1635
|
+
journal.finalize();
|
|
1636
|
+
} catch (e) { abortRun(journal, e, bail); return; }
|
|
1637
|
+
if (JSON_MODE) { jsonOk({ action: "reset", package: packageId, template: chosen.template, file, backup: backupFile, discardedRegions: lost.length, contentIntegrity: chosen.contentIntegrity }); return; }
|
|
1638
|
+
console.log(`Reset ${shortPath(file)} to ${packageId}:${chosen.template} verbatim. Previous contents saved at ${shortPath(backupFile)}.`);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// sync / adopt share the three-way apply.
|
|
1643
|
+
const unresolved = plan.conflicts.filter((id) => !Object.hasOwn(decisions, id));
|
|
1644
|
+
if (unresolved.length) {
|
|
1645
|
+
if (JSON_MODE || !process.stdin.isTTY) {
|
|
1646
|
+
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`);
|
|
1647
|
+
}
|
|
1648
|
+
for (const id of unresolved) {
|
|
1649
|
+
const region = plan.regions.find((r) => r.id === id);
|
|
1650
|
+
renderMergeRegion(region);
|
|
1651
|
+
const answer = promptLine(`[${id}] keep (l)ocal or take (p)ackage? `).trim().toLowerCase();
|
|
1652
|
+
if (answer === "l" || answer === "local") decisions[id] = "local";
|
|
1653
|
+
else if (answer === "p" || answer === "package") decisions[id] = "package";
|
|
1654
|
+
else bail("E_SYNC_AMBIGUOUS", `no choice made for ${id} — nothing was changed`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
let merged;
|
|
1659
|
+
try { merged = applyConfigMerge(localText, plan, decisions); }
|
|
1660
|
+
catch (e) { bail(e.code || "E_SYNC_FAILED", e.message); return; }
|
|
1661
|
+
|
|
1662
|
+
// Advancing the recorded base is the POINT of a sync, not a side effect of
|
|
1663
|
+
// changing bytes. Deciding "keep local" on every conflict changes nothing on
|
|
1664
|
+
// disk, but the decision must still be recorded — otherwise the base stays
|
|
1665
|
+
// behind and the identical conflict is re-presented on every future sync,
|
|
1666
|
+
// forever. So "nothing to do" means nothing applied AND the base already at
|
|
1667
|
+
// this exact template.
|
|
1668
|
+
const baseIsCurrent = adopted?.package === packageId
|
|
1669
|
+
&& adopted?.template === chosen.template
|
|
1670
|
+
&& adopted?.baseText === chosen.content;
|
|
1671
|
+
if (!merged.applied.length && baseIsCurrent) {
|
|
1672
|
+
if (JSON_MODE) { jsonOk({ action: sub, package: packageId, template: chosen.template, file, changed: false, baseAdvanced: false, applied: [], backup: null }); return; }
|
|
1673
|
+
console.log(`Nothing to do: ${shortPath(file)} and the recorded base are already at ${packageId}:${chosen.template}.`);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (!JSON_MODE) {
|
|
1678
|
+
console.log(`Plan for ${shortPath(file)} vs ${packageId}:${chosen.template}:`);
|
|
1679
|
+
for (const a of merged.applied) console.log(` [${a.id}] ${a.kind} → ${a.choice}`);
|
|
1680
|
+
console.log("");
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const changed = merged.text !== localText;
|
|
1684
|
+
const journal = openJournal(dir, bail);
|
|
1685
|
+
try {
|
|
1686
|
+
// Back up only when bytes actually change — a backup identical to the file
|
|
1687
|
+
// it shadows is noise the adopter has to reason about later.
|
|
1688
|
+
if (changed) copyFileAtomic(file, backupFile);
|
|
1689
|
+
if (changed) writeFileAtomic(file, merged.text);
|
|
1690
|
+
recordAdoption(dir, file, packageId, chosen, locked, adopted);
|
|
1691
|
+
journal.finalize();
|
|
1692
|
+
} catch (e) { abortRun(journal, e, bail); return; }
|
|
1693
|
+
|
|
1694
|
+
if (JSON_MODE) {
|
|
1695
|
+
jsonOk({
|
|
1696
|
+
action: sub, package: packageId, template: chosen.template, file, changed,
|
|
1697
|
+
baseAdvanced: true, applied: merged.applied, backup: changed ? backupFile : null,
|
|
1698
|
+
adoptedBase: join(adoptedTemplateDir(dir, packageId, chosen.template), "oats-config.yaml"),
|
|
1699
|
+
contentIntegrity: chosen.contentIntegrity,
|
|
1700
|
+
});
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (changed) console.log(`Applied ${merged.applied.length} change region(s) to ${shortPath(file)}; previous contents saved at ${shortPath(backupFile)}.`);
|
|
1704
|
+
else console.log(`No bytes changed in ${shortPath(file)} — you kept every local choice.`);
|
|
1705
|
+
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.`);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/** Open the run journal with the command's one-envelope guarantee intact. */
|
|
1709
|
+
function openJournal(dir, bail) {
|
|
1710
|
+
try { return beginRunJournal(dir); }
|
|
1711
|
+
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); throw e; }
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/** Undo a failed config mutation and report truthfully. */
|
|
1715
|
+
function abortRun(journal, e, bail) {
|
|
1716
|
+
const report = journal.rollback();
|
|
1717
|
+
bail(e.code || "E_CONFIG_WRITE_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/** Write the adopted base + metadata for the template just synced against, and
|
|
1721
|
+
* retire any previously adopted base so exactly one survives. */
|
|
1722
|
+
function recordAdoption(dir, file, packageId, chosen, locked, previous) {
|
|
1723
|
+
const written = writeAdoptedTemplate(dir, file, {
|
|
1724
|
+
package: packageId, template: chosen,
|
|
1725
|
+
root: { source: locked.source, version: locked.version, commit: locked.commit, path: locked.path },
|
|
1726
|
+
}, { writeConfig: false });
|
|
1727
|
+
if (previous && (previous.package !== packageId || previous.template !== chosen.template)) {
|
|
1728
|
+
rmSync(previous.dir, { recursive: true, force: true });
|
|
1729
|
+
const parent = dirname(previous.dir);
|
|
1730
|
+
try { if (!readdirSync(parent).length) rmSync(parent, { recursive: true, force: true }); } catch { /* sibling templates remain */ }
|
|
1731
|
+
}
|
|
1732
|
+
return written;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** Read one line from the terminal (human confirmation paths only). */
|
|
1736
|
+
function promptLine(question) {
|
|
1737
|
+
process.stdout.write(question);
|
|
1738
|
+
const buf = Buffer.alloc(1024);
|
|
1739
|
+
let read = 0;
|
|
1740
|
+
try { read = readSync(0, buf, 0, buf.length, null); } catch { return ""; }
|
|
1741
|
+
return buf.subarray(0, read).toString("utf8").replace(/\n.*$/s, "");
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/** One merge region, rendered for a human deciding what to do about it. */
|
|
1745
|
+
function renderMergeRegion(r) {
|
|
1746
|
+
const label = {
|
|
1747
|
+
upstream: "UPSTREAM ONLY — the package template changed this; your config did not",
|
|
1748
|
+
local: "LOCAL ONLY — you changed this; the package template did not (it stays)",
|
|
1749
|
+
conflict: "CONFLICT — both changed this; an explicit choice is required",
|
|
1750
|
+
agreed: "ALREADY AGREED — you and the package made the same change",
|
|
1751
|
+
}[r.kind];
|
|
1752
|
+
console.log(`[${r.id}] line ${r.local.start + 1}: ${label}`);
|
|
1753
|
+
const block = (title, text) => {
|
|
1754
|
+
if (!text) { console.log(` ${title}: (nothing)`); return; }
|
|
1755
|
+
for (const line of text.replace(/\n$/, "").split("\n")) console.log(` ${title}: ${line}`);
|
|
1756
|
+
};
|
|
1757
|
+
if (r.kind !== "local") block("package", r.template.text);
|
|
1758
|
+
if (r.kind !== "upstream") block("yours ", r.local.text);
|
|
1759
|
+
console.log("");
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/** oats list — installed packages, exported capabilities, scopes. */
|
|
1763
|
+
function listCmd() {
|
|
1764
|
+
const dir = dirFlag();
|
|
1765
|
+
// FAIL-CLOSED (maintainer finding 3): list RAISES on invalid locks — an
|
|
1766
|
+
// invalid lock must never render as usable/absent data.
|
|
1767
|
+
let pkgs, locks;
|
|
1768
|
+
try { pkgs = listInstalledPackages(dir); locks = readPackageLocks(dir); }
|
|
1769
|
+
catch (e) { JSON_MODE ? jsonFail(e.code || "invalid-lock", e.message || e) : die(e.message); return; }
|
|
1770
|
+
// Packages are TRANSPORT; capabilities are what is installed. So the listing
|
|
1771
|
+
// is capability-first: every row names its own provider, artifact, integrity,
|
|
1772
|
+
// trust and health, and the package rows keep only what the transport itself
|
|
1773
|
+
// pins. Trust is per capability — there is no package-level approval to list.
|
|
1774
|
+
const capabilities = [];
|
|
1775
|
+
for (const p of pkgs) {
|
|
1776
|
+
const rows = levelRows(locks, p.level);
|
|
1777
|
+
for (const c of p.capabilities) {
|
|
1778
|
+
const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
|
|
1779
|
+
capabilities.push({
|
|
1780
|
+
capability: c.id, version: c.version || null, package: p.package, level: p.level,
|
|
1781
|
+
path: c.path || null, dir: h.dir, integrity: c.integrity || null,
|
|
1782
|
+
installedIntegrity: h.integrity ?? null,
|
|
1783
|
+
layer: c.manifest?.layer || null, trusted: c.trusted === true, installed: c.installed,
|
|
1784
|
+
executableSurface: {
|
|
1785
|
+
commands: Object.keys(c.manifest?.commands || {}),
|
|
1786
|
+
hooks: Object.keys(c.manifest?.hooks || {}),
|
|
1787
|
+
environment: [...(c.manifest?.environment || [])],
|
|
1788
|
+
},
|
|
1789
|
+
status: h.status, code: h.code, detail: h.detail,
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
if (JSON_MODE) {
|
|
1794
|
+
jsonOk({
|
|
1795
|
+
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) })),
|
|
1796
|
+
capabilities,
|
|
1797
|
+
legacy: locks.legacy.map((l) => ({ file: l.file, level: l.level, lockfileVersion: l.lockfileVersion, capabilities: Object.keys(l.capabilities) })),
|
|
1798
|
+
});
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (!pkgs.length) console.log("No installed packages in this config chain.");
|
|
1802
|
+
const byPackage = new Map();
|
|
1803
|
+
for (const c of capabilities) {
|
|
1804
|
+
if (!byPackage.has(c.package)) byPackage.set(c.package, []);
|
|
1805
|
+
byPackage.get(c.package).push(c);
|
|
1806
|
+
}
|
|
1807
|
+
for (const p of pkgs) {
|
|
1808
|
+
console.log(`${p.package}@${p.version} [${levelOf(p.level)} ${shortPath(p.level)}]${p.locked ? "" : " UNLOCKED (no lock entry — reacquire)"}`);
|
|
1809
|
+
if (p.source) console.log(` source: ${p.source} path: ${p.path || "?"} commit: ${p.commit || "?"}`);
|
|
1810
|
+
for (const c of byPackage.get(p.package) || []) {
|
|
1811
|
+
const executable = c.executableSurface.commands.length || c.executableSurface.hooks.length || c.executableSurface.environment.length;
|
|
1812
|
+
const trust = executable ? (c.trusted ? " [trusted]" : " [executable — needs oats trust]") : "";
|
|
1813
|
+
console.log(` capability ${c.capability}${c.layer ? ` layer: ${c.layer}` : ""}${trust}`);
|
|
1814
|
+
// A capability whose bytes or provenance disagree with the lock is named
|
|
1815
|
+
// as broken HERE — never rendered as an ordinary usable row.
|
|
1816
|
+
if (c.status !== "ok" && c.status !== "untrusted") console.log(` ${c.status.toUpperCase()}: ${c.detail}`);
|
|
1817
|
+
}
|
|
1818
|
+
if (p.dependencies.length) console.log(` depends on: ${p.dependencies.join(", ")}`);
|
|
1819
|
+
}
|
|
1820
|
+
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`);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** oats remove <package> — refuses while config or dependent packages reference it. */
|
|
1824
|
+
function removeCmd() {
|
|
1825
|
+
const id = args[1];
|
|
1826
|
+
if (!id || id.startsWith("--")) JSON_MODE ? jsonFail("E_USAGE", "usage: oats remove <package> [--dir <dir>]") : die("usage: oats remove <package> [--dir <dir>]");
|
|
1827
|
+
const dir = dirFlag();
|
|
1828
|
+
let r;
|
|
1829
|
+
try { r = removePackage(dir, id); } catch (e) { cmdFail(e.code || "remove-blocked", e.message || e); return; }
|
|
1830
|
+
if (JSON_MODE) { jsonOk(r); return; }
|
|
1831
|
+
// There is no package directory to name — a package is transport, and what
|
|
1832
|
+
// actually leaves the disk is its materialized capability artifacts.
|
|
1833
|
+
console.log(`Removed package ${r.package} from ${shortPath(r.lockFile)}.`);
|
|
1834
|
+
console.log(r.capabilities.length
|
|
1835
|
+
? ` capabilities de-materialized: ${r.capabilities.join(", ")}`
|
|
1836
|
+
: " it supplied no capabilities at this scope.");
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/** The team boundary a guided migration walks, when the scope declares one.
|
|
1840
|
+
* A config the kernel refuses to resolve is not a reason to abort a migration
|
|
1841
|
+
* that only reads locks — discovery falls back to the explicit scope and says so. */
|
|
1842
|
+
function migrationTeamScope(dir, warnings) {
|
|
1843
|
+
try { return resolveOatsConfig(dir)?.team?.scope || undefined; }
|
|
1844
|
+
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; }
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const migratePlanRow = (s) => ({
|
|
1848
|
+
capability: s.capabilityId, action: s.action,
|
|
1849
|
+
package: s.package?.id || null, spec: s.package?.spec || null, via: s.package?.via || null,
|
|
1850
|
+
migratesTo: s.migratesTo || null,
|
|
1851
|
+
source: s.v1?.source || null, reason: s.reason || null, note: s.note || null,
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
/** `oats migrate --official` / `--recursive` — the guided existing-user upgrade.
|
|
1855
|
+
*
|
|
1856
|
+
* Plans EVERY visible lock-owning scope first (deterministic, side-effect
|
|
1857
|
+
* free), prints the complete per-scope plan, then applies scope by scope. Each
|
|
1858
|
+
* scope keeps the engine's transactional guarantee on its own: one scope's
|
|
1859
|
+
* failure leaves that scope byte-identical, never stops the others from being
|
|
1860
|
+
* reported truthfully, and makes the aggregate result nonzero. */
|
|
1861
|
+
function guidedMigrateCmd({ dir, dryRun, official, recursive }) {
|
|
1862
|
+
const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
1863
|
+
const opts = official ? { official: true } : {};
|
|
1864
|
+
const warnings = [];
|
|
1865
|
+
const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
|
|
1866
|
+
let scopes;
|
|
1867
|
+
try {
|
|
1868
|
+
scopes = recursive
|
|
1869
|
+
? discoverMigrationScopes(dir, { teamScope })
|
|
1870
|
+
: (existsSync(join(dir, OATS_LOCK_FILE)) ? [resolve(dir)] : []);
|
|
1871
|
+
} catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
1872
|
+
// Un-migrated OAS scopes are invisible to lock discovery (they own no
|
|
1873
|
+
// oats-lock.json), and silence would read as "nothing to migrate" — the
|
|
1874
|
+
// exact false success this probe exists to prevent (aweb-abfy.1).
|
|
1875
|
+
const oasScopes = recursive ? discoverOasScopes(dir, { teamScope }) : detectOasScopes(dir);
|
|
1876
|
+
|
|
1877
|
+
// ---- plan every scope BEFORE touching any of them ----
|
|
1878
|
+
const planned = [];
|
|
1879
|
+
for (const scope of scopes) {
|
|
1880
|
+
const file = join(scope, OATS_LOCK_FILE);
|
|
1881
|
+
try {
|
|
1882
|
+
const { plan, warnings: w } = migrateLegacyLock(scope, opts);
|
|
1883
|
+
const held = plan.filter((s) => s.action === "hold");
|
|
1884
|
+
const acquire = plan.filter((s) => s.action === "acquire");
|
|
1885
|
+
const formatOnly = plan.some((s) => s.action === "convert-format");
|
|
1886
|
+
const keep = plan.filter((s) => s.action === "retain" || s.action === "manual");
|
|
1887
|
+
// Both modes are ALL-OR-NOTHING: a v2 lock has no residue container, so a
|
|
1888
|
+
// scope converts completely or stays v1 in full. `keep` entries therefore
|
|
1889
|
+
// make a scope unconvertible rather than partially convertible — apply
|
|
1890
|
+
// refuses it, and the plan says so rather than promising "ready".
|
|
1891
|
+
// Official mode also never rewrites a scope it has no official work in.
|
|
1892
|
+
const convertible = acquire.length || formatOnly || (!official && plan.length);
|
|
1893
|
+
const status = held.length ? "held"
|
|
1894
|
+
: (convertible && !keep.length) ? "ready"
|
|
1895
|
+
: convertible ? "blocked"
|
|
1896
|
+
: "nothing";
|
|
1897
|
+
planned.push({ scope, file, status, plan, acquire, keep, held, warnings: w });
|
|
1898
|
+
} catch (e) {
|
|
1899
|
+
planned.push({ scope, file, status: "failed", plan: [], acquire: [], keep: [], held: [], warnings: [], error: { code: e.code || "invalid-lock", message: String(e.message || e) } });
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const planRows = planned.map((p) => ({
|
|
1903
|
+
level: p.scope, levelKind: levelOf(p.scope), file: p.file, status: p.status,
|
|
1904
|
+
plan: p.plan.map(migratePlanRow), warnings: p.warnings, error: p.error || null,
|
|
1905
|
+
}));
|
|
1906
|
+
|
|
1907
|
+
const actionable = planned.filter((p) => p.status === "ready" || p.status === "format-only");
|
|
1908
|
+
out(`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} — ${scopes.length} lock-owning scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
|
|
1909
|
+
for (const w of warnings) out(`WARNING: ${w}`);
|
|
1910
|
+
if (!scopes.length) out(oasScopes.length
|
|
1911
|
+
? " (no oats-lock.json found — but this is NOT an empty scope: un-migrated OAS files are present, see below)"
|
|
1912
|
+
: " (no oats-lock.json found — nothing to migrate)");
|
|
1913
|
+
for (const f of oasScopes) {
|
|
1914
|
+
out(`\n ${shortPath(f.dir)} UN-MIGRATED OAS SCOPE (${f.files.join(", ")})`);
|
|
1915
|
+
out(` HELD ${OAS_SCOPE_REMEDY}`);
|
|
1916
|
+
}
|
|
1917
|
+
for (const p of planned) {
|
|
1918
|
+
out(`\n ${shortPath(p.scope)} [${levelOf(p.scope)}] ${shortPath(p.file)}`);
|
|
1919
|
+
if (p.status === "failed") { out(` ERROR ${p.error.message} [${p.error.code}]`); continue; }
|
|
1920
|
+
for (const s of p.plan) {
|
|
1921
|
+
if (s.action === "convert-format") out(` format ${s.note}`);
|
|
1922
|
+
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)" : ""}`);
|
|
1923
|
+
else if (s.action === "hold") out(` HELD ${s.capabilityId} — ${s.reason}`);
|
|
1924
|
+
else out(` keep ${s.capabilityId}${s.v1?.source ? ` (${s.v1.source})` : ""} — not converted, entry kept unchanged`);
|
|
1925
|
+
}
|
|
1926
|
+
if (p.status === "nothing") out(" (nothing to migrate at this scope)");
|
|
1927
|
+
if (p.status === "blocked") {
|
|
1928
|
+
out(` BLOCKED this scope mixes convertible work with ${p.keep.length} entr${p.keep.length === 1 ? "y" : "ies"} that must stay lockfileVersion 1`);
|
|
1929
|
+
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");
|
|
1930
|
+
}
|
|
1931
|
+
if (p.status === "ready") {
|
|
1932
|
+
const renames = p.plan.filter((s) => s.migratesTo);
|
|
1933
|
+
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(", ")}`);
|
|
1934
|
+
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)`);
|
|
1935
|
+
out(" trust executable approvals are NOT carried over — they are re-earned after migrating (exact commands below)");
|
|
1936
|
+
}
|
|
1937
|
+
for (const w of p.warnings) out(` WARNING: ${w}`);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const result = {
|
|
1941
|
+
mode: official ? "official" : "generic", recursive, dryRun,
|
|
1942
|
+
boundary: resolve(dir), scopes: planRows, oasScopes, oasRemedy: oasScopes.length ? OAS_SCOPE_REMEDY : null,
|
|
1943
|
+
trust: [], requirements: [], nextCommands: [], warnings,
|
|
1944
|
+
};
|
|
1945
|
+
if (dryRun) {
|
|
1946
|
+
const failed = planned.filter((p) => p.status === "failed");
|
|
1947
|
+
const held = planned.filter((p) => p.status === "held");
|
|
1948
|
+
result.nextCommands = actionable.length ? [`oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`] : [];
|
|
1949
|
+
// A held or unplannable scope is NOT a ready migration: the dry run says so
|
|
1950
|
+
// with a nonzero result in both modes, so automation can never read
|
|
1951
|
+
// "planned successfully" as "this deployment can migrate now"
|
|
1952
|
+
// (reviewer-90dbb36). The complete plan travels under error.details.
|
|
1953
|
+
const mixed = planned.filter((p) => p.status === "blocked");
|
|
1954
|
+
const blocked = [
|
|
1955
|
+
...(held.length ? [`${held.length} scope${held.length > 1 ? "s" : ""} held (no official package mapping yet)`] : []),
|
|
1956
|
+
...(mixed.length ? [`${mixed.length} scope${mixed.length > 1 ? "s" : ""} blocked (entries that must stay lockfileVersion 1)`] : []),
|
|
1957
|
+
...(failed.length ? [`${failed.length} scope${failed.length > 1 ? "s" : ""} could not be planned`] : []),
|
|
1958
|
+
...(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`] : []),
|
|
1959
|
+
];
|
|
1960
|
+
if (JSON_MODE) {
|
|
1961
|
+
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); }
|
|
1962
|
+
jsonOk(result);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
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` : ""}.`);
|
|
1966
|
+
if (actionable.length) out(`Apply with: oats migrate${official ? " --official" : ""}${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
|
|
1967
|
+
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.");
|
|
1968
|
+
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.");
|
|
1969
|
+
if (blocked.length) die(`${blocked.join("; ")} (${actionable.length} ready)`);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// ---- apply, scope by scope (each independently transactional) ----
|
|
1974
|
+
const failures = [];
|
|
1975
|
+
// An un-migrated OAS scope is a failure, not an absence: apply must never
|
|
1976
|
+
// report overall success while one sits in the migrated universe.
|
|
1977
|
+
for (const f of oasScopes) {
|
|
1978
|
+
failures.push({ scope: f.dir, code: "oas-scope-unmigrated", message: `un-migrated OAS scope (${f.files.join(", ")}) — ${OAS_SCOPE_REMEDY}` });
|
|
1979
|
+
}
|
|
1980
|
+
for (const [i, p] of planned.entries()) {
|
|
1981
|
+
const row = planRows[i]; // planRows is built from planned, in order
|
|
1982
|
+
if (p.status === "failed") { row.status = "failed"; failures.push({ scope: p.scope, code: p.error.code, message: p.error.message }); continue; }
|
|
1983
|
+
if (p.status === "held") {
|
|
1984
|
+
row.status = "held";
|
|
1985
|
+
failures.push({ scope: p.scope, code: "official-mapping-unavailable", message: `held: ${p.held.map((s) => `${s.capabilityId} (${s.reason})`).join("; ")}` });
|
|
1986
|
+
out(`\nHELD ${shortPath(p.scope)} — left unchanged; its legacy capabilities keep working`);
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
if (p.status === "nothing") {
|
|
1990
|
+
// No official work here, so nothing is applied and nothing is rewritten.
|
|
1991
|
+
// Say what the scope KEPT — `retained`, never `residue`: these entries
|
|
1992
|
+
// were not left beside a conversion, there simply was no conversion.
|
|
1993
|
+
row.status = "skipped";
|
|
1994
|
+
if (p.keep.length) row.retained = p.keep.map((k) => k.capabilityId).filter(Boolean);
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
let r;
|
|
1998
|
+
try { r = applyLegacyLockMigration(p.scope, opts); }
|
|
1999
|
+
catch (e) {
|
|
2000
|
+
row.status = "failed";
|
|
2001
|
+
row.error = { code: e.code || "legacy-lock", message: String(e.message || e) };
|
|
2002
|
+
failures.push({ scope: p.scope, code: row.error.code, message: row.error.message });
|
|
2003
|
+
out(`\nFAILED ${shortPath(p.scope)} — ${row.error.message}`);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
row.status = r.skipped ? "skipped" : r.formatConverted ? "format-converted" : "migrated";
|
|
2007
|
+
row.migrated = r.migrated;
|
|
2008
|
+
// `retained` exists only for a SKIPPED scope left entirely on v1; a scope
|
|
2009
|
+
// that converts leaves nothing behind, and a mixed one is refused above.
|
|
2010
|
+
if (r.retained) row.retained = r.retained;
|
|
2011
|
+
row.warnings = r.warnings;
|
|
2012
|
+
for (const t of r.trust || []) result.trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(p.scope)}` });
|
|
2013
|
+
out(`\n ${shortPath(p.scope)}:`);
|
|
2014
|
+
for (const m of r.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
|
|
2015
|
+
for (const c of r.retained || []) out(` retained ${c} (this scope stays lockfileVersion 1, unchanged)`);
|
|
2016
|
+
for (const w of r.warnings) out(` WARNING: ${w}`);
|
|
2017
|
+
if (r.formatConverted) out(` format empty lockfileVersion 1 file → canonical v2`);
|
|
2018
|
+
else if (!r.skipped) out(` ${shortPath(r.file)} is now lockfileVersion 2 — config activation (from: installed) is unchanged`);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// ---- exact next commands: trust first, then the requirement/install pass ----
|
|
2022
|
+
const migratedScopes = planRows.filter((r) => r.status === "migrated").map((r) => r.level);
|
|
2023
|
+
let requirements = [];
|
|
2024
|
+
try { requirements = migratedScopes.length ? aggregateMissingRequirements(migratedScopes) : []; }
|
|
2025
|
+
catch (e) { result.warnings.push(`host requirements not aggregated: ${e.message}`); }
|
|
2026
|
+
result.requirements = requirements.map((req) => ({
|
|
2027
|
+
command: req.command, requestedBy: req.requestedBy,
|
|
2028
|
+
consentCommand: req.plan && !req.plan.unavailable && !req.invalid && !req.conflict
|
|
2029
|
+
? `oats install --accept-requirement ${req.command} --dir ${shellQuote(dir)}` : null,
|
|
2030
|
+
}));
|
|
2031
|
+
result.nextCommands = [
|
|
2032
|
+
...result.trust.map((t) => t.command),
|
|
2033
|
+
...result.requirements.filter((q) => q.consentCommand).map((q) => q.consentCommand),
|
|
2034
|
+
`oats install --dir ${shellQuote(dir)}`,
|
|
2035
|
+
];
|
|
2036
|
+
|
|
2037
|
+
if (JSON_MODE) {
|
|
2038
|
+
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); }
|
|
2039
|
+
jsonOk(result);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
out("\nNext steps:");
|
|
2043
|
+
if (result.trust.length) {
|
|
2044
|
+
out(" 1. Review and approve the executable surfaces (approvals are never carried over):");
|
|
2045
|
+
for (const t of result.trust) out(` ${t.command}`);
|
|
2046
|
+
} else out(" 1. No executable surfaces to approve.");
|
|
2047
|
+
for (const q of result.requirements) {
|
|
2048
|
+
if (q.consentCommand) out(` * Missing host command ${q.command}: ${q.consentCommand}`);
|
|
2049
|
+
}
|
|
2050
|
+
out(` 2. Verify the runtime closure and host requirements (already-installed requirements are not reinstalled):`);
|
|
2051
|
+
out(` oats install --dir ${shellQuote(dir)}`);
|
|
2052
|
+
if (failures.length) {
|
|
2053
|
+
out("\nFailures by scope:");
|
|
2054
|
+
for (const f of failures) out(` ${shortPath(f.scope)}: ${f.message} [${f.code}]`);
|
|
2055
|
+
die(`${failures.length} scope${failures.length > 1 ? "s" : ""} not migrated (${planRows.filter((r) => r.status === "migrated").length} migrated)`);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** `oats migrate --from-oas` — one transactional conversion per scope: rename
|
|
2060
|
+
* the OAS-named artifacts (breaks 1-3 of docs/migration-from-oas.md), then
|
|
2061
|
+
* chain the guided v1→v2 lock conversion under the SAME journal, so a failure
|
|
2062
|
+
* in either phase restores the original OAS bytes. Break 4 (stale v1
|
|
2063
|
+
* integrity) is resolved by re-acquisition, never by recomputing integrity. */
|
|
2064
|
+
function fromOasCmd({ dir, dryRun, recursive }) {
|
|
2065
|
+
const out = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
2066
|
+
const warnings = [];
|
|
2067
|
+
const teamScope = recursive ? migrationTeamScope(dir, warnings) : undefined;
|
|
2068
|
+
let scopes;
|
|
2069
|
+
if (recursive) scopes = discoverOasScopes(dir, { teamScope }).map((f) => f.dir);
|
|
2070
|
+
else {
|
|
2071
|
+
const chain = detectOasScopes(dir);
|
|
2072
|
+
scopes = chain.filter((f) => f.dir === resolve(dir)).map((f) => f.dir);
|
|
2073
|
+
if (!scopes.length && chain.length) {
|
|
2074
|
+
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`);
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
if (!scopes.length) {
|
|
2079
|
+
// Idempotency contract: a second run finds nothing and says so, exit 0.
|
|
2080
|
+
if (JSON_MODE) { jsonOk({ mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: [], trust: [], nextCommands: [], warnings }); return; }
|
|
2081
|
+
console.log("no oas-config.yaml / oas-lock.json found — nothing to migrate from OAS");
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const results = [];
|
|
2086
|
+
const trust = [];
|
|
2087
|
+
let failures = 0;
|
|
2088
|
+
for (const scope of scopes) {
|
|
2089
|
+
const plan = planFromOasScope(scope);
|
|
2090
|
+
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: [] };
|
|
2091
|
+
results.push(row);
|
|
2092
|
+
if (plan.errors.length) { row.status = "failed"; failures++; continue; }
|
|
2093
|
+
if (dryRun) {
|
|
2094
|
+
// Phase-2 preview against a temp mirror of the lock alone — guided
|
|
2095
|
+
// planning reads nothing else, so the preview is exact and touch-free.
|
|
2096
|
+
const lockStep = plan.steps.find((s) => s.to.endsWith(OATS_LOCK_FILE));
|
|
2097
|
+
if (lockStep) {
|
|
2098
|
+
const mirror = mkdtempSync(join(tmpdir(), "oats-from-oas-plan-"));
|
|
2099
|
+
try {
|
|
2100
|
+
copyFileSync(lockStep.from, join(mirror, OATS_LOCK_FILE));
|
|
2101
|
+
const { plan: mplan, warnings: mw } = migrateLegacyLock(mirror, { official: true });
|
|
2102
|
+
row.plan = mplan.map(migratePlanRow);
|
|
2103
|
+
row.warnings = mw;
|
|
2104
|
+
row.status = mplan.some((s) => s.action === "hold" || s.action === "manual") ? "held" : "ready";
|
|
2105
|
+
if (row.status === "held") failures++;
|
|
2106
|
+
} catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; }
|
|
2107
|
+
finally { rmSync(mirror, { recursive: true, force: true }); }
|
|
2108
|
+
} else row.status = "ready";
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
let journal;
|
|
2112
|
+
try { journal = applyFromOasScope(scope, plan); }
|
|
2113
|
+
catch (e) { row.status = "failed"; row.errors.push(String(e.message || e)); failures++; continue; }
|
|
2114
|
+
try {
|
|
2115
|
+
const r = applyLegacyLockMigration(scope, { official: true });
|
|
2116
|
+
journal.finalize();
|
|
2117
|
+
row.status = "migrated";
|
|
2118
|
+
row.migrated = r.migrated;
|
|
2119
|
+
// Phase 1 already rewrote the config's capability ids, so the guided
|
|
2120
|
+
// "update references in oats-config.yaml" warnings are satisfied here.
|
|
2121
|
+
row.warnings = r.warnings.filter((w) => !/update references in oats-config\.yaml/.test(w));
|
|
2122
|
+
for (const t of r.trust || []) trust.push({ ...t, command: `oats trust ${t.capability} --dir ${shellQuote(scope)}` });
|
|
2123
|
+
} catch (e) {
|
|
2124
|
+
journal.rollback();
|
|
2125
|
+
row.status = "failed";
|
|
2126
|
+
row.errors.push(`${String(e.message || e)} — scope restored to its original OAS state`);
|
|
2127
|
+
failures++;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
out(`oats migrate --from-oas${recursive ? " --recursive" : ""}${dryRun ? " --dry-run" : ""} — ${scopes.length} OAS scope${scopes.length === 1 ? "" : "s"} from ${shortPath(dir)}`);
|
|
2132
|
+
for (const w of warnings) out(`WARNING: ${w}`);
|
|
2133
|
+
for (const row of results) {
|
|
2134
|
+
out(`\n ${shortPath(row.scope)} [${row.status}]`);
|
|
2135
|
+
for (const s of row.steps) out(` ${s.kind === "rewrite" ? "rewrite " : "rename "} ${shortPath(s.from)} → ${shortPath(s.to)} (${s.note})`);
|
|
2136
|
+
for (const p of row.plan) out(` migrate ${p.capability} → package ${p.package}${p.migratesTo ? ` (as ${p.migratesTo})` : ""} [${p.action}]`);
|
|
2137
|
+
for (const m of row.migrated) out(` migrated ${m.capability} → package ${m.package}@${m.version}${m.migratedTo ? ` (as ${m.migratedTo})` : ""}`);
|
|
2138
|
+
for (const e of row.errors) out(` ERROR ${e}`);
|
|
2139
|
+
for (const w of row.warnings) out(` WARNING: ${w}`);
|
|
2140
|
+
}
|
|
2141
|
+
const nextCommands = [...trust.map((t) => t.command), ...(results.some((r) => r.status === "migrated") ? [`oats install --dir ${shellQuote(dir)}`] : [])];
|
|
2142
|
+
const result = { mode: "from-oas", recursive, dryRun, boundary: resolve(dir), scopes: results, trust, nextCommands, warnings };
|
|
2143
|
+
if (JSON_MODE) {
|
|
2144
|
+
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); }
|
|
2145
|
+
jsonOk(result);
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (!dryRun && nextCommands.length) {
|
|
2149
|
+
out("\nNext steps:");
|
|
2150
|
+
for (const c of nextCommands) out(` ${c}`);
|
|
2151
|
+
}
|
|
2152
|
+
if (dryRun) out(`\nDry run — nothing was changed. Apply with: oats migrate --from-oas${recursive ? " --recursive" : ""} --dir ${shellQuote(dir)}`);
|
|
2153
|
+
if (failures) die(`${failures} scope${failures > 1 ? "s" : ""} not converted`);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/** oats migrate — map this scope's v1 marketplace capability locks to package locks. */
|
|
2157
|
+
function migrateCmd() {
|
|
2158
|
+
const dir = dirFlag();
|
|
2159
|
+
const dryRun = args.includes("--dry-run");
|
|
2160
|
+
if (args.includes("--from-oas")) { fromOasCmd({ dir, dryRun, recursive: args.includes("--recursive") }); return; }
|
|
2161
|
+
if (args.includes("--official") || args.includes("--recursive")) {
|
|
2162
|
+
guidedMigrateCmd({ dir, dryRun, official: args.includes("--official"), recursive: args.includes("--recursive") });
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (dryRun) {
|
|
2166
|
+
let plan, warnings;
|
|
2167
|
+
try { ({ plan, warnings } = migrateLegacyLock(dir)); }
|
|
2168
|
+
catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
2169
|
+
if (!plan.length) {
|
|
2170
|
+
// "Nothing to migrate" on an OAS scope is a false success: the scope is
|
|
2171
|
+
// not empty, it is pre-rename, and this kernel cannot read it (aweb-abfy.1).
|
|
2172
|
+
const oas = detectOasScopes(dir);
|
|
2173
|
+
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; }
|
|
2174
|
+
}
|
|
2175
|
+
if (JSON_MODE) { jsonOk({ dryRun: true, plan, warnings }); return; }
|
|
2176
|
+
if (!plan.length) { console.log("Nothing to migrate at this scope."); return; }
|
|
2177
|
+
for (const s of plan) console.log(s.action === "convert-format" ? `${s.action.padEnd(14)} ${s.note}` : `${s.action.padEnd(10)} ${s.capabilityId}${s.package ? ` → ${s.package.spec}` : ""}`);
|
|
2178
|
+
for (const w of warnings) console.log(`WARNING: ${w}`);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
let r;
|
|
2182
|
+
try { r = applyLegacyLockMigration(dir); }
|
|
2183
|
+
catch (e) {
|
|
2184
|
+
const oas = detectOasScopes(dir);
|
|
2185
|
+
const oasNote = oas.length ? ` NOTE: un-migrated OAS scope files exist (${oas.map((f) => `${shortPath(f.dir)}: ${f.files.join(", ")}`).join("; ")}) — ${OAS_SCOPE_REMEDY}` : "";
|
|
2186
|
+
cmdFail(e.code || "legacy-lock", `${e.message || e}${oasNote}`); return;
|
|
2187
|
+
}
|
|
2188
|
+
if (JSON_MODE) { jsonOk(r); return; }
|
|
2189
|
+
for (const m of r.migrated) console.log(`migrated ${m.capability} → package ${m.package}@${m.version}`);
|
|
2190
|
+
for (const w of r.warnings) console.log(`WARNING: ${w}`);
|
|
2191
|
+
if (r.formatConverted) { console.log(`${shortPath(r.file)} was an empty lockfileVersion 1 file — converted to canonical v2.`); return; }
|
|
2192
|
+
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.`);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** oats update <package> — transactional package update with diff + trust reset. */
|
|
2196
|
+
function updatePackageCmd(id) {
|
|
2197
|
+
const dir = dirFlag();
|
|
2198
|
+
let r;
|
|
2199
|
+
try { r = updatePackage(dir, id); } catch (e) { cmdFail(e.code || "invalid-lock", e.message || e); return; }
|
|
2200
|
+
if (JSON_MODE) { jsonOk(r); return; }
|
|
2201
|
+
// A moved package root is reported even when the bytes are identical: the
|
|
2202
|
+
// lock now points somewhere else in the repository, and that is exactly the
|
|
2203
|
+
// change an operator must see (contract §7).
|
|
2204
|
+
const pathLine = () => console.log(` package path ${r.before.path} → ${r.after.path} (the selected package root MOVED in the source)`);
|
|
2205
|
+
if (!r.changed) {
|
|
2206
|
+
console.log(`${r.package} is already up to date (${r.after.version}, ${r.after.integrity}).`);
|
|
2207
|
+
if (r.pathChanged) pathLine();
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
console.log(`Updated ${r.package}: ${r.before.version} (${r.before.commit}) → ${r.after.version} (${r.after.commit})`);
|
|
2211
|
+
console.log(` integrity ${r.before.integrity} → ${r.after.integrity}`);
|
|
2212
|
+
if (r.pathChanged) pathLine();
|
|
2213
|
+
if (r.addedCapabilities.length) console.log(` + capabilities: ${r.addedCapabilities.join(", ")}`);
|
|
2214
|
+
if (r.removedCapabilities.length) console.log(` - capabilities: ${r.removedCapabilities.join(", ")}`);
|
|
2215
|
+
for (const w of r.depWarnings || []) console.log(`WARNING: ${w}`);
|
|
2216
|
+
if (r.invalidatedApprovals.length) console.log(` APPROVALS INVALIDATED (integrity changed): ${r.invalidatedApprovals.join(", ")} — re-approve with \`oats trust\` after review.`);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// ---------- init ----------
|
|
2220
|
+
/**
|
|
2221
|
+
* oats init [--raw] [--dir <dir>] [--knowledge <id>] [--messaging <id>] [--tasks <id>]
|
|
2222
|
+
*
|
|
2223
|
+
* Per-layer flags name a canonical capability ID or "none". A layer is filled by
|
|
2224
|
+
* a capability already at this scope (own store first — no config exists yet, so
|
|
2225
|
+
* the config-chain walk cannot see it), otherwise by acquiring the official
|
|
2226
|
+
* PACKAGE that supplies it through the materialization engine. Acquisition is
|
|
2227
|
+
* not activation, not executable trust and not requirement consent, and the
|
|
2228
|
+
* whole run is one transaction that rolls back on any failure.
|
|
2229
|
+
*/
|
|
2230
|
+
/** Resolve a template (name via outer-config `templates:` maps, local path, or git URL's
|
|
2231
|
+
* main-branch oats-config.yaml) into snapshot text with a provenance comment. */
|
|
2232
|
+
function loadTemplateConfig(spec, dir) {
|
|
2233
|
+
// THROWS typed errors rather than exiting: `oats init --template` reports
|
|
2234
|
+
// through the same single JSON envelope as every other init form.
|
|
2235
|
+
const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
|
|
2236
|
+
let source = spec;
|
|
2237
|
+
const isDirect = /^(https?:\/\/|git@|ssh:\/\/)/.test(spec) || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~");
|
|
2238
|
+
if (!isDirect) {
|
|
2239
|
+
let named;
|
|
2240
|
+
for (const cfg of configChain(dir)) {
|
|
2241
|
+
if (cfg.templates?.[spec]) { named = { value: cfg.templates[spec], level: cfg._level }; break; }
|
|
2242
|
+
}
|
|
2243
|
+
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`);
|
|
2244
|
+
source = /^(https?:\/\/|git@|ssh:\/\/)/.test(named.value) || named.value.startsWith("/") || named.value.startsWith("~")
|
|
2245
|
+
? named.value : resolve(named.level, named.value);
|
|
2246
|
+
}
|
|
2247
|
+
let body, provenance;
|
|
2248
|
+
if (/^(https?:\/\/|git@|ssh:\/\/)/.test(source)) {
|
|
2249
|
+
const tmp = mkdtempSync(join(tmpdir(), "oats-template-"));
|
|
2250
|
+
try {
|
|
2251
|
+
execFileSync("git", ["clone", "-q", "--depth", "1", source, tmp], { stdio: "inherit" });
|
|
2252
|
+
const cfgFile = join(tmp, "oats-config.yaml");
|
|
2253
|
+
if (!existsSync(cfgFile)) fail("E_TEMPLATE_SOURCE", `template repo has no oats-config.yaml on its default branch: ${source}`);
|
|
2254
|
+
body = readFileSync(cfgFile, "utf8");
|
|
2255
|
+
const commit = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
2256
|
+
provenance = `${source}@${commit.slice(0, 12)}`;
|
|
2257
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
2258
|
+
} else {
|
|
2259
|
+
// Replacer FUNCTION, not a replacement string: `$&`, `$'`, `` $` `` and
|
|
2260
|
+
// `$1` are substitution syntax in String.replace, and a home directory may
|
|
2261
|
+
// legally contain them.
|
|
2262
|
+
const path = resolve(source.replace(/^~\//, () => `${homedir()}/`));
|
|
2263
|
+
if (!existsSync(path)) fail("E_TEMPLATE_SOURCE", `template config not found: ${path}`);
|
|
2264
|
+
body = readFileSync(path, "utf8");
|
|
2265
|
+
provenance = path;
|
|
2266
|
+
}
|
|
2267
|
+
// Snapshot: strip template-registry keys that make no sense in the seeded config.
|
|
2268
|
+
const lines = body.replace(/\n*$/, "\n").split("\n");
|
|
2269
|
+
const scaffoldName = scaffoldConfigName(dir);
|
|
2270
|
+
const out = []; let skipping = false;
|
|
2271
|
+
for (const line of lines) {
|
|
2272
|
+
if (/^templates:\s*$/.test(line)) { skipping = true; continue; }
|
|
2273
|
+
if (skipping) { if (/^\S/.test(line) && line.trim()) skipping = false; else continue; }
|
|
2274
|
+
// Replacer FUNCTION, not a replacement string — this is a WRITE, so a
|
|
2275
|
+
// directory named `x$&y` would otherwise persist a corrupted `name:` line
|
|
2276
|
+
// (`name: xname: template-namey`).
|
|
2277
|
+
out.push(line.replace(/^name:.*$/, () => `name: ${scaffoldName}`));
|
|
2278
|
+
}
|
|
2279
|
+
return `# template: ${provenance} (snapshot — later template edits do not propagate)\n${out.join("\n").replace(/\n*$/, "\n")}`;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function init() {
|
|
2283
|
+
const raw = args.includes("--raw");
|
|
2284
|
+
const dir = dirFlag();
|
|
2285
|
+
const file = join(dir, "oats-config.yaml");
|
|
2286
|
+
const pkgSrc = flag("package");
|
|
2287
|
+
// Every init form — classic, --template and --package — reports through the
|
|
2288
|
+
// SAME one-envelope JSON boundary; nothing here may print two documents.
|
|
2289
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
2290
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
2291
|
+
if (existsSync(file)) bail("E_CONFIG_EXISTS", `${shortPath(file)} already exists — edit it or use \`oats use\``);
|
|
2292
|
+
// The scaffolded `name:` value is FILESYSTEM input. Refuse it up front, before
|
|
2293
|
+
// any init form mutates anything — a basename that cannot stay one YAML scalar
|
|
2294
|
+
// must abort the run, not be discovered halfway through a transaction.
|
|
2295
|
+
try { scaffoldConfigName(dir); }
|
|
2296
|
+
catch (e) { bail(e.code, e.message); return; }
|
|
2297
|
+
|
|
2298
|
+
if (pkgSrc && pkgSrc !== true) { initPackage(pkgSrc, dir, file); return; }
|
|
2299
|
+
if (pkgSrc === true) { bail("E_USAGE", "--package needs a package id, local path, or git URL"); return; }
|
|
2300
|
+
|
|
2301
|
+
const template = flag("template");
|
|
2302
|
+
if (template && template !== true) {
|
|
2303
|
+
let text;
|
|
2304
|
+
try { text = loadTemplateConfig(template, dir); }
|
|
2305
|
+
catch (e) { bail(e.code || "E_TEMPLATE_SOURCE", e.message); return; }
|
|
2306
|
+
// Seeding is a transaction too. A template can carry keys this kernel
|
|
2307
|
+
// refuses, or lock entries that will not restore; either way the config this
|
|
2308
|
+
// run wrote must not be left behind for the next command to trip over, and
|
|
2309
|
+
// the failure must be a typed error rather than an uncaught stack.
|
|
2310
|
+
let journal;
|
|
2311
|
+
try { journal = beginRunJournal(dir); }
|
|
2312
|
+
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
|
|
2313
|
+
let activated = [];
|
|
2314
|
+
try {
|
|
2315
|
+
writeFileSync(file, text);
|
|
2316
|
+
note(`Created ${shortPath(file)} (${levelOf(dir)} level) from template ${template}`);
|
|
2317
|
+
// The GATE is that the kernel can read this config: a template carrying a
|
|
2318
|
+
// retired key or a broken shape is a broken template, and leaving it
|
|
2319
|
+
// behind would break every later command at this scope.
|
|
2320
|
+
configChain(dir);
|
|
2321
|
+
restore(dir);
|
|
2322
|
+
// Activation is NOT a gate. A template's whole point is to seed policy you
|
|
2323
|
+
// then acquire — a capability it activates but nothing supplies yet is the
|
|
2324
|
+
// expected state right after seeding, not a reason to refuse the config.
|
|
2325
|
+
try { activated = resolveOatsConfig(dir).capabilities.map((c) => ({ capability: c.id, layer: c.layer || null })); }
|
|
2326
|
+
catch (e) { note(`NOTE: ${shortPath(file)} does not resolve yet — ${e.message}. Acquire what it activates (\`oats install <source>\`), then re-check with \`oats doctor\`.`); }
|
|
2327
|
+
journal.finalize();
|
|
2328
|
+
} catch (e) {
|
|
2329
|
+
const report = journal.rollback();
|
|
2330
|
+
const detail = `${shortPath(file)} could not be seeded from template ${template}: ${e.message}`;
|
|
2331
|
+
bail(e.code || "E_TEMPLATE_UNUSABLE", report.complete ? detail : `${detail} — ${report.summary}`);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (JSON_MODE) { jsonOk({ file, level: levelOf(dir), raw, adopted: false, template, acquired: [], activated, requirements: [] }); return; }
|
|
2335
|
+
offerTmuxMouseScrolling();
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
if (template === true) { bail("E_USAGE", "--template needs a name, local config path, or git URL"); return; }
|
|
2339
|
+
|
|
2340
|
+
// Per-layer overrides: --knowledge oats.okf, --messaging none, --tasks oats.jira …
|
|
2341
|
+
const overrides = {};
|
|
2342
|
+
const market = marketplaceCapabilities();
|
|
2343
|
+
// Own-scope manifests are read DIRECTLY: no oats-config.yaml exists here yet,
|
|
2344
|
+
// so the config-chain walk cannot see this scope's own store, and a
|
|
2345
|
+
// capability already installed here would look unknown.
|
|
2346
|
+
// Object.assign onto a null prototype, never an object spread: `{ ...map }`
|
|
2347
|
+
// re-plainifies the null-prototype sources, and `mans[v]` is then indexed with
|
|
2348
|
+
// a `--<layer>` value the operator typed — `--knowledge constructor` would
|
|
2349
|
+
// read Object.prototype.constructor as a manifest and report a layer mismatch
|
|
2350
|
+
// for a capability that does not exist.
|
|
2351
|
+
const mans = Object.assign(Object.create(null), market, capabilityManifests(dir), ownScopeCapabilityManifests(dir));
|
|
2352
|
+
for (const layer of LAYERS) {
|
|
2353
|
+
const v = flag(layer);
|
|
2354
|
+
if (v === undefined) continue;
|
|
2355
|
+
if (v === true || String(v).startsWith("--")) bail("E_USAGE", `--${layer} needs a canonical capability ID or "none"`);
|
|
2356
|
+
if (v !== "none") {
|
|
2357
|
+
// Known locally: its declared layer is checkable right now, before any
|
|
2358
|
+
// mutation. Otherwise the official catalog may still supply it, and the
|
|
2359
|
+
// layer is verified against the MATERIALIZED manifest after acquisition —
|
|
2360
|
+
// inside the run transaction, so a disagreement rolls the whole run back.
|
|
2361
|
+
if (mans[v]) {
|
|
2362
|
+
if (mans[v].layer !== layer) bail("E_LAYER_MISMATCH", `capability "${v}" declares layer "${mans[v].layer || "none"}", not "${layer}"`);
|
|
2363
|
+
} else if (!officialCapabilityPackage(v).available) {
|
|
2364
|
+
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"})`);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
overrides[layer] = v;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const defaults = raw
|
|
2371
|
+
? { knowledge: "none", messaging: "none", tasks: "none" }
|
|
2372
|
+
: { knowledge: "oats.okf", messaging: "oats.aweb", tasks: undefined };
|
|
2373
|
+
let layers = { ...defaults, ...overrides };
|
|
2374
|
+
|
|
2375
|
+
// Interactive TTY with no explicit layer flags: present each default and ask.
|
|
2376
|
+
// Non-interactive contexts (agents, CI) keep flags-or-silent-defaults — never hang.
|
|
2377
|
+
if (!raw && !JSON_MODE && process.stdin.isTTY && process.stdout.isTTY && !Object.keys(overrides).length) {
|
|
2378
|
+
const byLayer = (l) => Object.values(mans).filter((m) => m.layer === l).map((m) => m.capability);
|
|
2379
|
+
console.log("Fundamental layers for this scope — Enter keeps the default, or type a capability id / \"none\":");
|
|
2380
|
+
const ask = (prompt) => {
|
|
2381
|
+
process.stdout.write(prompt);
|
|
2382
|
+
const buffer = Buffer.alloc(256);
|
|
2383
|
+
let length = 0;
|
|
2384
|
+
try { length = readSync(process.stdin.fd, buffer, 0, buffer.length); } catch { /* EOF */ }
|
|
2385
|
+
return buffer.subarray(0, length).toString("utf8").trim();
|
|
2386
|
+
};
|
|
2387
|
+
for (const layer of LAYERS) {
|
|
2388
|
+
const options = byLayer(layer);
|
|
2389
|
+
const def = layers[layer] || "none";
|
|
2390
|
+
while (true) {
|
|
2391
|
+
const answer = ask(` ${layer.padEnd(10)} [${def}] (options: ${[...options, "none"].join(", ")}): `);
|
|
2392
|
+
if (!answer) break;
|
|
2393
|
+
if (answer === "none" || options.includes(answer)) { layers[layer] = answer; break; }
|
|
2394
|
+
console.log(` unknown "${answer}" — pick one of: ${[...options, "none"].join(", ")}`);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
if ((layers.messaging || "none") !== "none") console.log(" (messaging via aweb: after init, run `oats aweb setup` for guided onboarding)");
|
|
2398
|
+
}
|
|
2399
|
+
// ---- Everything below MUTATES. It is ONE run-level transaction: the config
|
|
2400
|
+
// file, the lock, the flat capability artifacts, the capability .gitignore
|
|
2401
|
+
// and any `.agents` anchor this run creates roll back together. A capability
|
|
2402
|
+
// that was already installed at this scope before the run is restored
|
|
2403
|
+
// byte-identically — this run only ever undoes its own changes. ----
|
|
2404
|
+
let journal;
|
|
2405
|
+
try { journal = beginRunJournal(dir); }
|
|
2406
|
+
catch (e) { bail(e.code || "E_JOURNAL_FAILED", e.message); return; }
|
|
2407
|
+
const abort = (e, code) => {
|
|
2408
|
+
const report = journal.rollback();
|
|
2409
|
+
bail(code || e.code || "E_INIT_FAILED", report.complete ? e.message : `${e.message} — ${report.summary}`);
|
|
2410
|
+
};
|
|
2411
|
+
|
|
2412
|
+
const acquisitions = [];
|
|
2413
|
+
let resolved;
|
|
2414
|
+
const lines = [
|
|
2415
|
+
`name: ${scaffoldConfigName(dir)}`,
|
|
2416
|
+
"",
|
|
2417
|
+
"# ── Agent types (families) — declared here by name (or via `oats type add`);",
|
|
2418
|
+
"# each soul opts in via `type: <name>` in its soul.yaml. Capability entries can target them.",
|
|
2419
|
+
"# agent-types:",
|
|
2420
|
+
"# reviewers:",
|
|
2421
|
+
"# description: Agents that review changes",
|
|
2422
|
+
"",
|
|
2423
|
+
"capabilities:",
|
|
2424
|
+
" # Fundamental layers — exclusive slots; a capability entry or an explicit none.",
|
|
2425
|
+
" layers:",
|
|
2426
|
+
];
|
|
2427
|
+
try {
|
|
2428
|
+
for (const layer of LAYERS) {
|
|
2429
|
+
const selected = layers[layer];
|
|
2430
|
+
if (!selected) { lines.push(` # ${layer}: (unset — inherits from outer config scopes; set an entry or "none")`); continue; }
|
|
2431
|
+
if (selected === "none") { lines.push(` ${layer}: none`); continue; }
|
|
2432
|
+
// Already here (own scope first — see above), or acquired now.
|
|
2433
|
+
const manifest = ownScopeCapabilityManifest(dir, selected)
|
|
2434
|
+
|| capabilityManifest(selected, dir)
|
|
2435
|
+
|| acquireLayerCapability(dir, selected, layer, acquisitions, note);
|
|
2436
|
+
lines.push(` ${layer}:`);
|
|
2437
|
+
lines.push(` capability: ${manifest.capability}`);
|
|
2438
|
+
if (String(manifest._origin).startsWith("installed:")) { lines.push(" from: installed"); lines.push(` # injection-override: .agents/injections/capabilities/${manifest.capability}.md`); }
|
|
2439
|
+
else if (String(manifest._origin).startsWith("owned:")) { lines.push(" from: owned"); lines.push(` # injection edited at source: .agents/capabilities/owned/${manifest.capability}/injects/`); }
|
|
2440
|
+
}
|
|
2441
|
+
lines.push(
|
|
2442
|
+
" # Additive capabilities — non-exclusive; target global, agent-types, or souls.",
|
|
2443
|
+
" # additive:",
|
|
2444
|
+
" # <capability-id>:",
|
|
2445
|
+
" # from: installed",
|
|
2446
|
+
" # global: true",
|
|
2447
|
+
" # # injection-override: .agents/injections/capabilities/<capability-id>.md",
|
|
2448
|
+
"",
|
|
2449
|
+
"# ── Work modes — optional per-mode env bootstrap.",
|
|
2450
|
+
"# `setup:` runs inside each NEW worktree right after `git worktree add` — use it",
|
|
2451
|
+
"# for env setup scripts (installs, .env copying, direnv, mise, etc.).",
|
|
2452
|
+
"# The path is relative to this config's directory.",
|
|
2453
|
+
"work-modes:",
|
|
2454
|
+
" worktree:",
|
|
2455
|
+
" # setup: scripts/setup-worktree.sh",
|
|
2456
|
+
"",
|
|
2457
|
+
"# ── OATS defaults — the framework's baseline instruction block.",
|
|
2458
|
+
"oats:",
|
|
2459
|
+
" # injection-override: .agents/injections/oats-defaults/oats.md",
|
|
2460
|
+
);
|
|
2461
|
+
writeFileSync(file, lines.join("\n") + "\n");
|
|
2462
|
+
// Resolve INSIDE the transaction: a config this run wrote that cannot
|
|
2463
|
+
// resolve is a broken scope, so it fails the init and rolls back rather
|
|
2464
|
+
// than being left behind for the next command to trip over.
|
|
2465
|
+
resolved = resolveOatsConfig(dir);
|
|
2466
|
+
journal.finalize();
|
|
2467
|
+
} catch (e) { abort(e); return; }
|
|
2468
|
+
|
|
2469
|
+
note(`Created ${shortPath(file)} (${levelOf(dir)} level${raw ? ", raw" : ""})`);
|
|
2470
|
+
// Acquisition is not activation, not executable trust, and not requirement
|
|
2471
|
+
// consent — say so per acquisition rather than implying the layer is ready.
|
|
2472
|
+
for (const a of acquisitions) {
|
|
2473
|
+
if (!a.executableSurface.length) continue;
|
|
2474
|
+
note(`Executable surfaces from ${a.package || "the marketplace"} are blocked until trusted: ${a.executableSurface.map((c) => `oats trust ${c}`).join("; ")}`);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
const r = resolved;
|
|
2478
|
+
const activated = [];
|
|
2479
|
+
for (const cap of r.capabilities) {
|
|
2480
|
+
activated.push({ capability: cap.id, layer: cap.layer || null });
|
|
2481
|
+
note(`Activated: ${cap.id}${cap.layer ? ` → ${cap.layer}` : ""}`);
|
|
2482
|
+
for (const miss of cap.missingRequires) note(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
|
|
2483
|
+
}
|
|
2484
|
+
if (JSON_MODE) {
|
|
2485
|
+
jsonOk({
|
|
2486
|
+
file, level: levelOf(dir), raw, adopted: false,
|
|
2487
|
+
layers: Object.fromEntries(LAYERS.map((l) => [l, layers[l] ?? null])),
|
|
2488
|
+
acquired: acquisitions, activated,
|
|
2489
|
+
// Same facts the human run prints, in the same run: who asked, why, and
|
|
2490
|
+
// the ONE copyable command that consents to installing it. Init never
|
|
2491
|
+
// runs it — reporting a requirement and acting on it are separate steps,
|
|
2492
|
+
// and an agent reading this envelope must be able to tell them apart.
|
|
2493
|
+
requirements: r.capabilities.flatMap((c) => c.missingRequires.map((m) => ({
|
|
2494
|
+
capability: c.id, command: m.command, why: m.why || null, install: m.install || null,
|
|
2495
|
+
consentCommand: `oats install --accept-requirement ${m.command} --dir ${shellQuote(dir)}`,
|
|
2496
|
+
}))),
|
|
2497
|
+
});
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
offerTmuxMouseScrolling();
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/** Acquire the capability backing one fundamental layer at classic-init time.
|
|
2504
|
+
*
|
|
2505
|
+
* Catalog-first: when an official package supplies the capability it comes
|
|
2506
|
+
* through the package engine — flat materialization, a capability-materialization
|
|
2507
|
+
* lock, and NO implicit executable trust. The legacy standalone-capability route
|
|
2508
|
+
* survives only for marketplace capabilities the official catalog cannot supply
|
|
2509
|
+
* today, and it is the only branch that still writes a v1 lock.
|
|
2510
|
+
*
|
|
2511
|
+
* Throws on every failure: the caller holds the run journal, and exiting here
|
|
2512
|
+
* would strand its backup. */
|
|
2513
|
+
function acquireLayerCapability(dir, capId, layer, acquired, note) {
|
|
2514
|
+
const fail = (code, message) => { const e = new Error(message); e.code = code; throw e; };
|
|
2515
|
+
const official = officialCapabilityPackage(capId);
|
|
2516
|
+
if (official.available) {
|
|
2517
|
+
const acq = acquirePackage(dir, official.package);
|
|
2518
|
+
if (!acq.capabilities.some((c) => c.capability === capId)) {
|
|
2519
|
+
fail("E_LAYER_NOT_EXPORTED", `package ${official.package} does not export capability "${capId}" — it exports ${acq.capabilities.map((c) => c.capability).join(", ") || "nothing"}`);
|
|
2520
|
+
}
|
|
2521
|
+
// The layer is verified against the manifest actually WRITTEN TO DISK, never
|
|
2522
|
+
// against the marketplace copy or the catalog's word for it.
|
|
2523
|
+
const manifest = ownScopeCapabilityManifest(dir, capId);
|
|
2524
|
+
if (!manifest) fail("E_LAYER_UNREADABLE", `capability "${capId}" was materialized but its manifest under ${shortPath(installedCapabilityDir(dir, capId))} is unreadable`);
|
|
2525
|
+
if (manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
|
|
2526
|
+
const executableSurface = acq.capabilities
|
|
2527
|
+
.filter((c) => c.executableSurface?.commands?.length || c.executableSurface?.hooks?.length || c.executableSurface?.environment?.length)
|
|
2528
|
+
.map((c) => c.capability);
|
|
2529
|
+
acquired.push({
|
|
2530
|
+
layer, capability: capId, route: "package", package: official.package, via: official.via,
|
|
2531
|
+
packages: acq.installed.map((p) => ({ package: p.package, version: p.version || null, commit: p.commit || null })),
|
|
2532
|
+
lockFile: acq.lockFile, trusted: false, executableSurface,
|
|
2533
|
+
});
|
|
2534
|
+
note(`Acquired package ${official.package} for the ${layer} layer → ${capId} (${acq.installed.map((p) => `${p.package}@${p.version}`).join(", ")}) → ${shortPath(acq.lockFile)}`);
|
|
2535
|
+
return { ...manifest, _origin: `installed:${dir}` };
|
|
2536
|
+
}
|
|
2537
|
+
const market = marketplaceCapabilities();
|
|
2538
|
+
if (!market[capId]) {
|
|
2539
|
+
fail("E_UNKNOWN_CAPABILITY", `capability "${capId}" is not acquired at ${shortPath(dir)}, is not in the marketplace (${Object.keys(market).join(", ") || "empty"}), and no official package supplies it`);
|
|
2540
|
+
}
|
|
2541
|
+
// Legacy route: kernel-bundled marketplace capabilities predate the official
|
|
2542
|
+
// packages, ship with the kernel already installed, and keep their v1 lock and
|
|
2543
|
+
// acquisition-time trust until the catalog covers them.
|
|
2544
|
+
const r = acquireCapability(dir, capId);
|
|
2545
|
+
try {
|
|
2546
|
+
writeCapabilityLock(dir, r.manifest.capability, {
|
|
2547
|
+
source: r.source, version: r.manifest.version || null, integrity: r.integrity, trustedExecutables: true,
|
|
2548
|
+
});
|
|
2549
|
+
} catch (e) { rmSync(r.dest, { recursive: true, force: true }); throw e; }
|
|
2550
|
+
if (r.manifest.layer !== layer) fail("E_LAYER_MISMATCH", `capability "${capId}" declares layer "${r.manifest.layer || "none"}", not "${layer}"`);
|
|
2551
|
+
acquired.push({ layer, capability: capId, route: "marketplace", package: null, via: "marketplace", packages: [], lockFile: join(dir, OATS_LOCK_FILE), trusted: true, executableSurface: [] });
|
|
2552
|
+
note(`Acquired ${r.manifest.capability}@${r.manifest.version} from the marketplace → ${shortPath(r.dest)}`);
|
|
2553
|
+
return { ...r.manifest, _origin: `installed:${dir}` };
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/** Capability manifests physically present at THIS scope's own store.
|
|
2557
|
+
*
|
|
2558
|
+
* `capabilityManifests` walks the config chain, so during `oats init` — when no
|
|
2559
|
+
* oats-config.yaml exists at the target scope yet — this scope is not a level and
|
|
2560
|
+
* its own installed/ and owned/ capabilities are invisible. Init reads them
|
|
2561
|
+
* directly instead, which is also what makes a same-run acquisition visible to
|
|
2562
|
+
* the rest of the run. */
|
|
2563
|
+
function ownScopeCapabilityManifests(dir) {
|
|
2564
|
+
// Capability-id keyed — never answer for `constructor`/`toString`. Belt and
|
|
2565
|
+
// braces on the write side (store directory names are identity-validated at
|
|
2566
|
+
// acquisition); it matters on the read side, where `oats init` indexes this
|
|
2567
|
+
// map with a `--<layer>` flag value the operator typed.
|
|
2568
|
+
const out = Object.create(null);
|
|
2569
|
+
for (const [sub, origin] of [[installedCapabilitiesDir(dir), "installed"], [ownedCapabilitiesDir(dir), "owned"]]) {
|
|
2570
|
+
if (!existsSync(sub)) continue;
|
|
2571
|
+
let entries;
|
|
2572
|
+
try { entries = readdirSync(sub, { withFileTypes: true }); } catch { continue; }
|
|
2573
|
+
for (const e of entries) {
|
|
2574
|
+
// Dot-prefixed entries are transaction staging, never installed content.
|
|
2575
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
2576
|
+
let raw;
|
|
2577
|
+
try { raw = JSON.parse(readFileSync(join(sub, e.name, "oats.json"), "utf8")); } catch { continue; }
|
|
2578
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
2579
|
+
// Strip BEFORE the spread. `_dir` and `_origin` are reassigned just after
|
|
2580
|
+
// it, but every OTHER annotation in the namespace — `_capabilityLock`,
|
|
2581
|
+
// `_package`, `_soulDir` … — would flow straight out of an
|
|
2582
|
+
// artifact-controlled document. That is the exact shape the kernel's own
|
|
2583
|
+
// manifest reader was fixed for, and this map is merged OVER that
|
|
2584
|
+
// stripped one, so leaving it raw kept the forgery carrier alive.
|
|
2585
|
+
const m = stripInternalAnnotations(raw);
|
|
2586
|
+
if (typeof m.capability === "string") out[m.capability] = { ...m, _dir: join(sub, e.name), _origin: `${origin}:${dir}` };
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
return out;
|
|
2590
|
+
}
|
|
2591
|
+
const ownScopeCapabilityManifest = (dir, capId) => ownScopeCapabilityManifests(dir)[capId];
|
|
2592
|
+
|
|
2593
|
+
// ---------- roster: status / spawn / retire / create ----------
|
|
2594
|
+
function status() {
|
|
2595
|
+
if (args.includes("--team")) return statusTeam();
|
|
2596
|
+
const root = ensureRoot(dirFlag());
|
|
2597
|
+
const data = listInstances(root);
|
|
2598
|
+
if (args.includes("--json")) { console.log(JSON.stringify({ root, agents: data }, null, 2)); return; }
|
|
2599
|
+
console.log(`oats status — agents root ${shortPath(root)}\n`);
|
|
2600
|
+
if (data.length === 0) { console.log(" (no agents — create one with `oats create <name>`)"); return; }
|
|
2601
|
+
for (const a of data) {
|
|
2602
|
+
console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
|
|
2603
|
+
if (a.description) console.log(` ${a.description}`);
|
|
2604
|
+
for (const i of a.instances) {
|
|
2605
|
+
console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
const defs = listAgentDefs(process.cwd());
|
|
2609
|
+
if (defs.length) console.log(`\n importable defs: ${defs.map((d) => d.name).join(", ")}`);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function statusTeam() {
|
|
2613
|
+
const ctx = dirFlag();
|
|
2614
|
+
const r = resolveOatsConfig(ctx);
|
|
2615
|
+
if (!r.team) die(`no team declared in the config chain from ${shortPath(ctx)} — add a "team:" block (name, optional id) at the deployment scope`);
|
|
2616
|
+
const roots = teamAgentRoots(r.team.scope);
|
|
2617
|
+
const payload = { team: r.team, roots: [] };
|
|
2618
|
+
for (const root of roots) payload.roots.push({ root, agents: listInstances(root) });
|
|
2619
|
+
if (args.includes("--json")) { console.log(JSON.stringify(payload, null, 2)); return; }
|
|
2620
|
+
console.log(`oats status — team ${r.team.name}${r.team.id ? ` (${r.team.id})` : ""} [scope: ${shortPath(r.team.scope)}]\n`);
|
|
2621
|
+
if (!roots.length) { console.log(" (no agents/ directories in the team scope)"); return; }
|
|
2622
|
+
for (const { root, agents } of payload.roots) {
|
|
2623
|
+
console.log(` ${shortPath(root)}`);
|
|
2624
|
+
if (!agents.length) { console.log(" (no agents)"); continue; }
|
|
2625
|
+
for (const a of agents) {
|
|
2626
|
+
console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""}${a.description ? ` — ${a.description}` : ""}`);
|
|
2627
|
+
for (const i of a.instances) console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"}`);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function spawnCmd() {
|
|
2633
|
+
// JSON mode: contract envelope, stable error codes, stderr-only progress.
|
|
2634
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
2635
|
+
const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
|
|
2636
|
+
const name = args[1];
|
|
2637
|
+
if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
|
|
2638
|
+
// Retired boundary flags (maintainer transport ruling): fail LOUDLY before
|
|
2639
|
+
// ANY side effect — including root discovery and local-agent upsert (an
|
|
2640
|
+
// --instructions-file spawn must not scaffold/overwrite a local soul before
|
|
2641
|
+
// this rejection; reviewer-b671de0).
|
|
2642
|
+
if (args.includes("--instance")) bail("E_BAD_ARGS", "--instance was removed by the runtime-boundary ruling — use --purpose <slug> (deterministic <agent>-<purpose> naming)");
|
|
2643
|
+
if (args.includes("--ephemeral")) bail("E_BAD_ARGS", "--ephemeral was removed by the runtime-boundary ruling — declare the agent in a capability manifest (agents:) for automatic ephemeral semantics");
|
|
2644
|
+
let root;
|
|
2645
|
+
try { root = ensureRoot(dirFlag()); }
|
|
2646
|
+
catch (e) { bail("E_NO_DEPLOYMENT", e.message || e); throw e; }
|
|
2647
|
+
let agent = findAgent(root, name);
|
|
2648
|
+
const instrFile = flag("instructions-file");
|
|
2649
|
+
const defFile = flag("def-file");
|
|
2650
|
+
if (!agent && !instrFile && !defFile) {
|
|
2651
|
+
// Capability-defined agent: a package's `agents:` soul, active in this context.
|
|
2652
|
+
const capAgent = findCapabilityAgent(dirFlag(), root, name);
|
|
2653
|
+
if (capAgent) {
|
|
2654
|
+
agent = capAgent;
|
|
2655
|
+
note(`(capability agent: "${name}" from ${capAgent.capability} — fresh soul, instances home locally)`);
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
if (!agent && !instrFile && !defFile) {
|
|
2659
|
+
// Cross-repo lookup: the soul may live in a sibling repo of the team scope.
|
|
2660
|
+
// Unique match wins; the instance homes with its owning repo's agents root.
|
|
2661
|
+
const teamHit = findTeamAgent(dirFlag(), name);
|
|
2662
|
+
const remote = (teamHit?.matches || []).filter((m) => resolve(m.root) !== resolve(root));
|
|
2663
|
+
if (remote.length > 1) bail("E_AMBIGUOUS_SOUL", `soul "${name}" found in multiple team repos: ${remote.map((m) => shortPath(m.root)).join(", ")} — re-run with --dir <that repo>`);
|
|
2664
|
+
if (remote.length === 1) {
|
|
2665
|
+
root = remote[0].root;
|
|
2666
|
+
agent = remote[0].agent;
|
|
2667
|
+
note(`(cross-repo: soul "${name}" found at ${shortPath(root)} — instance homes there)`);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
// local agents: create/update from raw instructions or a single-file def
|
|
2671
|
+
if (instrFile || defFile || !agent) {
|
|
2672
|
+
if (!agent && !instrFile && !defFile) {
|
|
2673
|
+
const def = listAgentDefs(process.cwd()).find((d) => d.name === name);
|
|
2674
|
+
if (!def) bail("E_UNKNOWN_AGENT", `unknown agent "${name}" (known: ${listAgents(root).map((a) => a.name).join(", ") || "none"}; importable defs: ${listAgentDefs(process.cwd()).map((d) => d.name).join(", ") || "none"}) — pass --instructions-file or --def-file to create a local agent`);
|
|
2675
|
+
agent = upsertLocalAgent(root, { name: def.name, file: def.path, repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model") });
|
|
2676
|
+
} else if (!agent || agent.kind === "local") {
|
|
2677
|
+
agent = upsertLocalAgent(root, {
|
|
2678
|
+
name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
|
|
2679
|
+
repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"),
|
|
2680
|
+
});
|
|
2681
|
+
} else {
|
|
2682
|
+
bail("E_BAD_ARGS", `"${name}" is a persistent agent — spawn it without --instructions-file/--def-file`);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
// Lineage is explicit: --relation child|sibling|parent|unrelated anchors the new
|
|
2686
|
+
// instance to --relative-to <instance>. --parent X is sugar for
|
|
2687
|
+
// --relative-to X --relation child (agents spawning sub-agents pass their own
|
|
2688
|
+
// name, e.g. --parent "$OATS_INSTANCE"). Without a relation, the spawn is
|
|
2689
|
+
// operator-origin and lands top-level — ambient env vars in the shell are
|
|
2690
|
+
// never treated as parentage.
|
|
2691
|
+
const parent = flag("parent");
|
|
2692
|
+
if (parent !== undefined && (parent === true || !String(parent).trim())) bail("E_BAD_ARGS", "--parent needs an instance name");
|
|
2693
|
+
let relation = flag("relation");
|
|
2694
|
+
if (relation !== undefined && (relation === true || !String(relation).trim())) bail("E_BAD_ARGS", "--relation needs a value: child|sibling|parent|unrelated");
|
|
2695
|
+
if (relation && !RELATIONS.includes(relation)) bail("E_BAD_ARGS", `unknown --relation "${relation}" (child|sibling|parent|unrelated)`);
|
|
2696
|
+
let relativeTo = flag("relative-to");
|
|
2697
|
+
if (relativeTo !== undefined && (relativeTo === true || !String(relativeTo).trim())) bail("E_BAD_ARGS", "--relative-to needs an instance name");
|
|
2698
|
+
if (relation && relation !== "unrelated" && !relativeTo) bail("E_BAD_ARGS", `--relation ${relation} requires --relative-to <instance>`);
|
|
2699
|
+
if (relativeTo && !relation) bail("E_BAD_ARGS", "--relative-to requires --relation child|sibling|parent");
|
|
2700
|
+
if (relation === "unrelated" && relativeTo) bail("E_BAD_ARGS", "--relation unrelated takes no --relative-to");
|
|
2701
|
+
if (parent && (relation || relativeTo)) bail("E_BAD_ARGS", "--parent is sugar for --relative-to <instance> --relation child — use one form, not both");
|
|
2702
|
+
if (parent) { relation = "child"; relativeTo = parent; }
|
|
2703
|
+
// Attached agents are ALWAYS children (design decision): the only relation
|
|
2704
|
+
// flags allowed are the child form — required when the workDir is not an
|
|
2705
|
+
// instance's own <home>/work (integration worktrees). The kernel verifies
|
|
2706
|
+
// ownership canonically (including soul-default attached mode).
|
|
2707
|
+
if ((flag("work") === "attached") && relation && relation !== "child") bail("E_BAD_ARGS", "attached agents are always children of the work-tree owner — only --parent <instance> (or --relation child) is valid with --work attached");
|
|
2708
|
+
// NOTE: explicit "unrelated" is passed through to the kernel.
|
|
2709
|
+
if (relativeTo && relation !== "unrelated") {
|
|
2710
|
+
// findInstanceHome also sees capability-defined agents' instance homes
|
|
2711
|
+
// (local-agents/<name>/ without a local soul) — e.g. a reviewer passing
|
|
2712
|
+
// --parent "$OATS_INSTANCE" from a capability agent.
|
|
2713
|
+
if (!findInstanceHome(root, relativeTo) && !findTeamInstance(dirFlag(), relativeTo)) bail(parent ? "E_PARENT_NOT_FOUND" : "E_RELATIVE_NOT_FOUND", `${parent ? "--parent" : "--relative-to"} "${relativeTo}" does not match any known instance`);
|
|
2714
|
+
}
|
|
2715
|
+
const taskText = flag("task");
|
|
2716
|
+
if (taskText === true) bail("E_BAD_ARGS", "--task needs a value (use --task-file for long tasks)");
|
|
2717
|
+
const taskFileFlag = flag("task-file");
|
|
2718
|
+
if (taskFileFlag === true) bail("E_BAD_ARGS", "--task-file needs a path");
|
|
2719
|
+
if (taskFileFlag && !existsSync(taskFileFlag)) bail("E_BAD_ARGS", `--task-file not found: ${taskFileFlag}`);
|
|
2720
|
+
const relativeRoot = flag("relative-root");
|
|
2721
|
+
if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
|
|
2722
|
+
if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
|
|
2723
|
+
let r;
|
|
2724
|
+
try {
|
|
2725
|
+
r = spawnInstance(root, agent, {
|
|
2726
|
+
purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
|
|
2727
|
+
repo: flag("repo") || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
|
|
2728
|
+
work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), model: flag("model"), branch: flag("branch"),
|
|
2729
|
+
launch: !args.includes("--no-launch"),
|
|
2730
|
+
});
|
|
2731
|
+
} catch (e) {
|
|
2732
|
+
// A typed CLI failure keeps ITS OWN code: re-badging an unsafe-config-key
|
|
2733
|
+
// (raised by the readers spawn walks) as E_SPAWN_FAILED tells an agent
|
|
2734
|
+
// consumer the spawn mechanism broke, when the fixable fact is a poisoned
|
|
2735
|
+
// document the message already names. The shared boundary renders it.
|
|
2736
|
+
if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
|
|
2737
|
+
bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
2738
|
+
}
|
|
2739
|
+
if (JSON_MODE) {
|
|
2740
|
+
// Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
|
|
2741
|
+
jsonOk({
|
|
2742
|
+
instance: r.instance, agent: r.agent, home: r.home, work: r.work,
|
|
2743
|
+
branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
|
|
2744
|
+
tmux: r.tmux || null, repo: r.repo || null, runtime: r.runtime || null,
|
|
2745
|
+
model: r.model || null, parent: r.parentInstance || null,
|
|
2746
|
+
sibling: r.siblingInstance || null, relation: r.relation || null,
|
|
2747
|
+
spawnOrigin: r.spawnOrigin, attach: r.attach,
|
|
2748
|
+
});
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
|
|
2752
|
+
console.log(` home: ${shortPath(r.home)}`);
|
|
2753
|
+
if (!r.launched) console.log(` launch: (cd ${shortPath(r.home)} && ${r.command})`);
|
|
2754
|
+
for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
|
|
2755
|
+
console.log(` attach: ${r.attach}`);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function retireCmd() {
|
|
2759
|
+
const name = args[1];
|
|
2760
|
+
if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
|
|
2761
|
+
const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
|
|
2762
|
+
if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
|
|
2763
|
+
if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
|
|
2764
|
+
let root = ensureRoot(dirFlag());
|
|
2765
|
+
// Cross-repo: the instance may home in a sibling repo of the team scope.
|
|
2766
|
+
if (!listAgents(root).some((a) => existsSync(join(a._dir, "instances", name)))) {
|
|
2767
|
+
const hit = findTeamInstance(dirFlag(), name);
|
|
2768
|
+
if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; console.log(`(cross-repo: instance homes at ${shortPath(root)})`); }
|
|
2769
|
+
}
|
|
2770
|
+
const r = retireInstance(root, name, { self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
|
|
2771
|
+
// Forced removal past an incomplete cleanup: the home is gone because the
|
|
2772
|
+
// operator said so, but the external state it owed is still out there and
|
|
2773
|
+
// nobody else will mention it again.
|
|
2774
|
+
if (r.forcedIncomplete) {
|
|
2775
|
+
console.error(`Removed ${r.retired} under --force with cleanup INCOMPLETE — this external state was NOT cleaned up and is now yours to remove by hand:`);
|
|
2776
|
+
for (const f of r.forcedIncomplete) console.error(` ${f}`);
|
|
2777
|
+
}
|
|
2778
|
+
if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); if (r.rollbackIncomplete) process.exit(1); return; }
|
|
2779
|
+
// An unsuccessful cleanup retry must NOT read as a completed retirement: the
|
|
2780
|
+
// home and its external state are still there, and a zero exit would tell
|
|
2781
|
+
// both a human and any script that the work is done.
|
|
2782
|
+
if (r.rollbackIncomplete) {
|
|
2783
|
+
console.error(`Cleanup for ${r.retired} is INCOMPLETE — the instance home is retained at ${r.retainedHome} because external state may still exist:`);
|
|
2784
|
+
for (const f of r.rollbackIncomplete) console.error(` ${f}`);
|
|
2785
|
+
console.error(`Fix the cause and re-run \`oats retire ${r.retired}\`; the home holds the state that cleanup needs.`);
|
|
2786
|
+
process.exit(1);
|
|
2787
|
+
}
|
|
2788
|
+
console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}${r.branchDeleted ? ", branch deleted" : ""}${r.harvested?.length ? `, harvested: ${r.harvested.join(", ")}` : ""}`);
|
|
2789
|
+
// Preserving work and not saying so leaves the operator believing it is gone,
|
|
2790
|
+
// which is most of the harm of deleting it. Name the classes and the path.
|
|
2791
|
+
for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
|
|
2792
|
+
console.log(`Work that was not committed has been preserved: ${recovery.classes.join(", ")}`);
|
|
2793
|
+
console.log(` ${recovery.path}`);
|
|
2794
|
+
}
|
|
2795
|
+
if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
async function paneCmd() {
|
|
2799
|
+
die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
function createCmd() {
|
|
2803
|
+
const name = args[1];
|
|
2804
|
+
if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--runtime pi|claude] [--model <m>] [--instructions-file <f>]");
|
|
2805
|
+
const local = args.includes("--local");
|
|
2806
|
+
const startDir = dirFlag();
|
|
2807
|
+
// --local can BOOTSTRAP a deployment: with no agents/ or local-agents/ yet,
|
|
2808
|
+
// anchor at the enclosing git repo (else the start dir) — people can use OATS
|
|
2809
|
+
// with local agents alone.
|
|
2810
|
+
let root = findRoot(startDir);
|
|
2811
|
+
if (!root) {
|
|
2812
|
+
if (!local) root = ensureRoot(startDir); // keeps the pointed error for committed souls
|
|
2813
|
+
else root = join(defaultRepo(startDir) || resolve(startDir), "agents");
|
|
2814
|
+
}
|
|
2815
|
+
const instrFile = flag("instructions-file");
|
|
2816
|
+
const r = coreCreateAgent(root, {
|
|
2817
|
+
name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || defaultRepo(process.cwd()),
|
|
2818
|
+
work: flag("work"), runtime: flag("runtime"), model: flag("model"),
|
|
2819
|
+
instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
|
|
2820
|
+
});
|
|
2821
|
+
if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
2822
|
+
console.log(`Created ${r.kind === "local" ? "LOCAL agent (uncommitted — soul lives in local-agents/, gitignored)" : "agent"} "${r.agent}" — soul at ${shortPath(r.soul)}`);
|
|
2823
|
+
console.log(`Edit ${shortPath(join(r.soul, "AGENTS.md"))} to define its role, then: oats spawn ${r.agent} --task "..."`);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// ---------- capability command dispatch ----------
|
|
2827
|
+
/**
|
|
2828
|
+
* oats <namespace> <command> [args…] — run a command an active capability
|
|
2829
|
+
* declares in its manifest (`commands: { name: "script args" }`).
|
|
2830
|
+
* Kernel subcommands take precedence over capability namespaces.
|
|
2831
|
+
*/
|
|
2832
|
+
function capabilityCommand() {
|
|
2833
|
+
// JSON-aware boundary: in --json mode every dispatch failure — inactive or
|
|
2834
|
+
// untrusted capability, duplicate namespace, unknown subcommand, broken
|
|
2835
|
+
// metadata/manifests, malformed command values — must still emit exactly
|
|
2836
|
+
// one envelope object on stdout. The WHOLE dispatcher runs inside the
|
|
2837
|
+
// boundary; only "no namespace matched" escapes (returns false to the help
|
|
2838
|
+
// fallthrough).
|
|
2839
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
2840
|
+
const NOT_DISPATCHED = Symbol("not-dispatched");
|
|
2841
|
+
let outcome;
|
|
2842
|
+
try { outcome = dispatch(); }
|
|
2843
|
+
catch (e) {
|
|
2844
|
+
// Unexpected throw from discovery/trust/decoding: keep the envelope contract.
|
|
2845
|
+
bail("E_CAPABILITY_BROKEN", e.message || e);
|
|
2846
|
+
throw e;
|
|
2847
|
+
}
|
|
2848
|
+
return outcome !== NOT_DISPATCHED;
|
|
2849
|
+
|
|
2850
|
+
function dispatch() {
|
|
2851
|
+
let activeIds;
|
|
2852
|
+
let context = process.cwd();
|
|
2853
|
+
let teamCtx;
|
|
2854
|
+
const instanceHome = process.env.PI_AGENT_HOME || process.env.OATS_HOME;
|
|
2855
|
+
const metaFile = instanceHome && join(instanceHome, "instance.json");
|
|
2856
|
+
// Capability-id keyed — never answer for `constructor`/`toString`. Belt and
|
|
2857
|
+
// braces: the ids come from instance.json, which spawn wrote from resolved
|
|
2858
|
+
// manifests. Null-prototype because the dispatcher indexes it with the
|
|
2859
|
+
// namespace the operator typed on the command line.
|
|
2860
|
+
let capSettings = Object.create(null);
|
|
2861
|
+
try {
|
|
2862
|
+
if (metaFile && existsSync(metaFile)) {
|
|
2863
|
+
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
2864
|
+
activeIds = (meta.capabilities || []).map((c) => c.id);
|
|
2865
|
+
for (const c of meta.capabilities || []) capSettings[c.id] = c.settings || {};
|
|
2866
|
+
context = meta.repo || context;
|
|
2867
|
+
// Team: the spawn-time snapshot, but fall back to live config — instances
|
|
2868
|
+
// spawned before a team: block was declared have no snapshot.
|
|
2869
|
+
teamCtx = meta.team || resolveOatsConfig(context).team;
|
|
2870
|
+
} else {
|
|
2871
|
+
const resolved = resolveOatsConfig(context, flag("soul"));
|
|
2872
|
+
activeIds = resolved.capabilities.map((c) => c.id);
|
|
2873
|
+
for (const c of resolved.capabilities) capSettings[c.id] = c.settings || {};
|
|
2874
|
+
teamCtx = resolved.team;
|
|
2875
|
+
}
|
|
2876
|
+
} catch (e) { bail("E_CONFIG_BROKEN", e.message || e); throw e; }
|
|
2877
|
+
const mans = Object.values(capabilityManifests(context)).filter((m) => m.command === cmd && m.commands);
|
|
2878
|
+
if (!mans.length) return NOT_DISPATCHED;
|
|
2879
|
+
if (mans.length > 1) bail("E_DUPLICATE_NAMESPACE", `duplicate operational command namespace "${cmd}": ${mans.map((m) => m.capability).join(", ")}`);
|
|
2880
|
+
const m = mans[0];
|
|
2881
|
+
if (!activeIds.includes(m.capability)) bail("E_CAPABILITY_INACTIVE", `${m.capability} command namespace is not active in the current context/instance`);
|
|
2882
|
+
const trust = capabilityTrust(m, context);
|
|
2883
|
+
if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
|
|
2884
|
+
const sub = args[1];
|
|
2885
|
+
const cmds = Object.keys(m.commands);
|
|
2886
|
+
// Distinguish an ABSENT key from a declared-but-invalid value: a manifest
|
|
2887
|
+
// entry of "" / 0 / false / null is a broken capability, not an unknown
|
|
2888
|
+
// command (it is listed in cmds).
|
|
2889
|
+
if (!sub || !Object.prototype.hasOwnProperty.call(m.commands, sub)) {
|
|
2890
|
+
if (JSON_MODE) jsonFail("E_UNKNOWN_COMMAND", `oats ${cmd}: ${sub ? `unknown command "${sub}"` : "missing command"} — commands: ${cmds.join(", ") || "(none)"}`);
|
|
2891
|
+
console.error(`oats ${cmd} — commands: ${cmds.join(", ") || "(none)"}`);
|
|
2892
|
+
process.exit(sub ? 1 : 0);
|
|
2893
|
+
}
|
|
2894
|
+
// Command values come from third-party manifests — validate before decoding.
|
|
2895
|
+
const spec = m.commands[sub];
|
|
2896
|
+
if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: manifest command must be a non-empty string (got ${JSON.stringify(spec)})`);
|
|
2897
|
+
const [script, ...rest] = spec.trim().split(/\s+/);
|
|
2898
|
+
let abs;
|
|
2899
|
+
try { abs = capabilityExecutablePath(m, script); }
|
|
2900
|
+
catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
|
|
2901
|
+
if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(m._dir, script)})`);
|
|
2902
|
+
const r = spawnSync("node", [abs, ...rest, ...args.slice(2)], { stdio: "inherit", env: {
|
|
2903
|
+
...process.env, OATS_CAPABILITY: m.capability,
|
|
2904
|
+
// Package-runtime boundary: dispatched commands receive the active
|
|
2905
|
+
// capability's EFFECTIVE settings (instance snapshot or resolved context),
|
|
2906
|
+
// same contract as lifecycle hooks — capabilities read their settings
|
|
2907
|
+
// here instead of importing the kernel resolver.
|
|
2908
|
+
OATS_SETTINGS: JSON.stringify(capSettings[m.capability] || {}),
|
|
2909
|
+
// PATH is not a trusted runtime boundary (maintainer finding 1): pass the
|
|
2910
|
+
// canonical absolute executable of THIS CLI; official consumers execFile
|
|
2911
|
+
// it directly and never resolve `oats` from PATH or a shell.
|
|
2912
|
+
OATS_CLI_BIN: CLI_BIN,
|
|
2913
|
+
OATS_TEAM_NAME: teamCtx?.name || "", OATS_TEAM_ID: teamCtx?.id || "", OATS_TEAM_SCOPE: teamCtx?.scope || "",
|
|
2914
|
+
} });
|
|
2915
|
+
// Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
|
|
2916
|
+
if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
|
|
2917
|
+
process.exit(r.status ?? 1);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// ---------- agent types ----------
|
|
2922
|
+
function typeCmd() {
|
|
2923
|
+
const sub = args[1];
|
|
2924
|
+
const dir = dirFlag();
|
|
2925
|
+
const file = join(dir, "oats-config.yaml");
|
|
2926
|
+
if (sub === "list") {
|
|
2927
|
+
const seen = new Map();
|
|
2928
|
+
for (const cfg of configChain(dir)) for (const [name, spec] of Object.entries(cfg["agent-types"] || {})) if (!seen.has(name)) seen.set(name, { desc: spec?.description, level: cfg._level });
|
|
2929
|
+
if (!seen.size) { console.log("No agent types declared in the config chain."); return; }
|
|
2930
|
+
for (const [name, { desc, level }] of seen) console.log(`${name} ${desc ? `— ${desc} ` : ""}[${shortPath(level)}]`);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (sub !== "add" || !args[2] || args[2].startsWith("--")) die("usage: oats type add <name> [--description <d>] [--dir <dir>] | oats type list [--dir <dir>]");
|
|
2934
|
+
const name = args[2];
|
|
2935
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) die(`agent type "${name}" must be lowercase alphanumeric/hyphens`);
|
|
2936
|
+
const description = flag("description");
|
|
2937
|
+
let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
|
|
2938
|
+
const cfg = existsSync(file) ? withConfigFile(file, () => parseYamlNested(text)) : {};
|
|
2939
|
+
// Own-property: `constructor` is a legal agent-type name, and a plain lookup
|
|
2940
|
+
// would report it as already declared in a config that never mentions it.
|
|
2941
|
+
const declaredTypes = cfg["agent-types"];
|
|
2942
|
+
if (declaredTypes && typeof declaredTypes === "object" && Object.hasOwn(declaredTypes, name)) die(`agent type "${name}" already declared in ${shortPath(file)}`);
|
|
2943
|
+
// The NAME is already held to a strict grammar above; the DESCRIPTION was
|
|
2944
|
+
// written verbatim onto its own line, so it could inject document the same
|
|
2945
|
+
// way a `--settings` value could.
|
|
2946
|
+
const block = [` ${name}:`, ...(description ? [` description: ${assertSafeConfigValue(description, "--description")}`] : [])];
|
|
2947
|
+
const lines = text.replace(/\n*$/, "\n").split("\n");
|
|
2948
|
+
// Drop the scaffold comment block once a real agent-types block exists.
|
|
2949
|
+
const scaffold = lines.findIndex((l) => /^# ── Agent types/.test(l));
|
|
2950
|
+
if (scaffold >= 0) {
|
|
2951
|
+
let e = scaffold;
|
|
2952
|
+
while (e < lines.length && (/^#/.test(lines[e]) || lines[e] === "")) { if (lines[e] === "" && !/^#/.test(lines[e + 1] || "x")) break; e++; }
|
|
2953
|
+
lines.splice(scaffold, e - scaffold);
|
|
2954
|
+
}
|
|
2955
|
+
const start = lines.findIndex((l) => /^agent-types:\s*(#.*)?$/.test(l));
|
|
2956
|
+
if (start >= 0) {
|
|
2957
|
+
let end = start + 1;
|
|
2958
|
+
while (end < lines.length && (/^\s/.test(lines[end]) || lines[end] === "")) { if (lines[end] === "" && !/^\s/.test(lines[end + 1] || "x")) break; end++; }
|
|
2959
|
+
lines.splice(end, 0, ...block);
|
|
2960
|
+
} else {
|
|
2961
|
+
lines.splice(1, 0, "", "agent-types:", ...block);
|
|
2962
|
+
}
|
|
2963
|
+
writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"));
|
|
2964
|
+
console.log(`Declared agent type "${name}" at ${levelOf(dir)} level (${shortPath(file)})`);
|
|
2965
|
+
console.log(`Souls join it with: oats create <agent> --type ${name} (or type: ${name} in soul.yaml)`);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// ---------- injection eject ----------
|
|
2969
|
+
function injectCmd() {
|
|
2970
|
+
const sub = args[1];
|
|
2971
|
+
const target = args[2];
|
|
2972
|
+
if (sub !== "eject" || !target || target.startsWith("--")) die("usage: oats inject eject <capability-id|oats> [--dir <dir>]");
|
|
2973
|
+
const dir = dirFlag();
|
|
2974
|
+
const file = join(dir, "oats-config.yaml");
|
|
2975
|
+
if (!existsSync(file)) die(`no oats-config.yaml at ${shortPath(dir)} — run oats init first`);
|
|
2976
|
+
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)");
|
|
2977
|
+
const isWorkMode = false;
|
|
2978
|
+
const isKernel = target === "oats";
|
|
2979
|
+
const src = isKernel ? packagedInject("oats", dir) : isWorkMode ? packagedInject(`work-${target}`, dir) : packagedInject(target, dir);
|
|
2980
|
+
if (!src) die(`no packaged default injection found for "${target}"`);
|
|
2981
|
+
const rel = isKernel ? ".agents/injections/oats-defaults/oats.md" : isWorkMode ? `.agents/injections/workmodes/${target}.md` : `.agents/injections/capabilities/${target}.md`;
|
|
2982
|
+
const destAbs = join(dir, rel);
|
|
2983
|
+
if (existsSync(destAbs)) die(`${shortPath(destAbs)} already exists — edit it directly (it is already your override)`);
|
|
2984
|
+
let text = readFileSync(file, "utf8");
|
|
2985
|
+
if (!isWorkMode && !isKernel) {
|
|
2986
|
+
const caps = readCapabilitiesModel(file);
|
|
2987
|
+
const entry = Object.values(caps.layers).find((e) => e && e !== "none" && e.capability === target) || caps.additive[target];
|
|
2988
|
+
if (!entry) die(`capability "${target}" has no entry in ${shortPath(file)} — activate it first (oats use ${target})`);
|
|
2989
|
+
const m = capabilityManifest(target, dir);
|
|
2990
|
+
const owned = entry.from === "owned" || String(entry.from || "").startsWith("path:") || String(m?._origin || "").startsWith("owned:") || String(m?._origin || "").startsWith("path:");
|
|
2991
|
+
if (owned) die(`"${target}" is owned/path-sourced — you own its source; edit its injects/ file directly instead of ejecting`);
|
|
2992
|
+
entry["injection-override"] = rel;
|
|
2993
|
+
text = replaceCapabilitiesBlock(text, caps);
|
|
2994
|
+
} else {
|
|
2995
|
+
const lines = text.replace(/\n*$/, "\n").split("\n");
|
|
2996
|
+
const headRe = isKernel ? /^oats:\s*(#.*)?$/ : /^work-modes:\s*(#.*)?$/;
|
|
2997
|
+
let idx = lines.findIndex((l) => headRe.test(l));
|
|
2998
|
+
if (idx < 0) { lines.push("", isKernel ? "oats:" : "work-modes:"); idx = lines.length - 1; }
|
|
2999
|
+
if (isKernel) {
|
|
3000
|
+
lines.splice(idx + 1, 0, ` injection-override: ${rel}`);
|
|
3001
|
+
const c = lines.findIndex((l, i2) => i2 > idx + 1 && l.trim() === `# injection-override: ${rel}`);
|
|
3002
|
+
if (c >= 0) lines.splice(c, 1);
|
|
3003
|
+
} else {
|
|
3004
|
+
let mIdx = lines.findIndex((l, i2) => i2 > idx && new RegExp(`^ ${target}:`).test(l));
|
|
3005
|
+
if (mIdx < 0) { lines.splice(idx + 1, 0, ` ${target}:`, ` injection-override: ${rel}`); }
|
|
3006
|
+
else {
|
|
3007
|
+
lines.splice(mIdx + 1, 0, ` injection-override: ${rel}`);
|
|
3008
|
+
const c = lines.findIndex((l, i2) => i2 > mIdx + 1 && l.trim() === `# injection-override: ${rel}`);
|
|
3009
|
+
if (c >= 0) lines.splice(c, 1);
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
text = lines.join("\n").replace(/\n*$/, "\n");
|
|
3013
|
+
}
|
|
3014
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
3015
|
+
writeFileSync(destAbs, readFileSync(src, "utf8"));
|
|
3016
|
+
writeFileSync(file, text);
|
|
3017
|
+
console.log(`Ejected packaged injection → ${shortPath(destAbs)}`);
|
|
3018
|
+
console.log(`Set injection-override in ${shortPath(file)}. Edit the ejected file; it no longer tracks package updates.`);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// ---------- update ----------
|
|
3022
|
+
function updateCmd() {
|
|
3023
|
+
const checkOnly = args.includes("--check");
|
|
3024
|
+
let latest;
|
|
3025
|
+
try { latest = execFileSync("npm", ["view", "@awebai/oats", "version"], { encoding: "utf8", timeout: 30000 }).trim(); }
|
|
3026
|
+
catch (e) { die(`cannot check npm for the latest version: ${e.message}`); }
|
|
3027
|
+
console.log(`@awebai/oats installed: ${OATS_VERSION} latest: ${latest}`);
|
|
3028
|
+
// pi bridge, if a pi installation carries it.
|
|
3029
|
+
let piBridge;
|
|
3030
|
+
const piPkg = join(homedir(), ".pi", "agent", "npm", "node_modules", "@awebai", "oats-pi", "package.json");
|
|
3031
|
+
if (existsSync(piPkg)) piBridge = JSON.parse(readFileSync(piPkg, "utf8")).version;
|
|
3032
|
+
if (piBridge) console.log(`@awebai/oats-pi installed: ${piBridge} latest: ${latest} (published in lockstep)`);
|
|
3033
|
+
if (latest === OATS_VERSION && (!piBridge || piBridge === latest)) { console.log("Up to date."); return; }
|
|
3034
|
+
const steps = [];
|
|
3035
|
+
if (latest !== OATS_VERSION) steps.push(`npm install -g @awebai/oats@${latest}`);
|
|
3036
|
+
if (piBridge && piBridge !== latest) steps.push(`pi uninstall npm:@awebai/oats-pi@${piBridge}`, `pi install npm:@awebai/oats-pi@${latest}`);
|
|
3037
|
+
console.log("\nUpdate steps:");
|
|
3038
|
+
for (const s of steps) console.log(` ${s}`);
|
|
3039
|
+
if (checkOnly) { console.log("\n(--check: not executing)"); return; }
|
|
3040
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
3041
|
+
if (interactive) {
|
|
3042
|
+
process.stdout.write("\nRun these now? [y/N] ");
|
|
3043
|
+
const buf = Buffer.alloc(16);
|
|
3044
|
+
let answer = "";
|
|
3045
|
+
try { answer = buf.toString("utf8", 0, readSync(0, buf, 0, 16)).trim().toLowerCase(); } catch { /* no input */ }
|
|
3046
|
+
if (answer !== "y" && answer !== "yes") { console.log("Not updating."); return; }
|
|
3047
|
+
} else if (!args.includes("--yes")) {
|
|
3048
|
+
console.log("\nNon-interactive: pass --yes to execute, or run the steps yourself.");
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
for (const s of steps) {
|
|
3052
|
+
console.log(`\n$ ${s}`);
|
|
3053
|
+
const [bin, ...rest] = s.split(/\s+/);
|
|
3054
|
+
const r = spawnSync(bin, rest, { stdio: "inherit" });
|
|
3055
|
+
if (r.status !== 0) die(`step failed: ${s}`);
|
|
3056
|
+
}
|
|
3057
|
+
console.log(`\nUpdated to ${latest}. Now verify each deployment: run \`oats doctor\` at your workspace/repo scopes — it reports config spellings this version rejects, version skew, and missing requirements. Restart running pi sessions to pick up the new bridge.`);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// ---------- version (Desktop CLI API v1 probe) ----------
|
|
3061
|
+
function versionCmd() {
|
|
3062
|
+
if (JSON_MODE) {
|
|
3063
|
+
// EXACT Desktop API v1 probe payload — one JSON object, nothing else on
|
|
3064
|
+
// stdout. Desktop accepts desktopApi === 1 and a compatible semver range.
|
|
3065
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1 }));
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// ---------- the record (core) and experimental tools over it ----------
|
|
3072
|
+
// The turn record is the core: capture, recall, setup are kernel-level
|
|
3073
|
+
// subcommands, dispatched to packages/record (shipped inside this package —
|
|
3074
|
+
// see "files" in package.json). The record bins parse process.argv.slice(2)
|
|
3075
|
+
// themselves, so the consumed subcommand words are spliced out first.
|
|
3076
|
+
// Everything that selects or synthesizes over the record (dress, spawn,
|
|
3077
|
+
// segments, mind) is EXPERIMENTAL and ships only in the oats repo checkout,
|
|
3078
|
+
// under packages/experimental — absent from the published tarball on
|
|
3079
|
+
// purpose, so its presence is exactly its status.
|
|
3080
|
+
const EXPERIMENTAL_CMDS = new Set(["dress", "spawn", "segments", "mind"]);
|
|
3081
|
+
async function recordCmd(sub) {
|
|
3082
|
+
process.argv.splice(2, 1);
|
|
3083
|
+
await import(new URL(`../packages/record/bin/${sub}.mjs`, import.meta.url));
|
|
3084
|
+
}
|
|
3085
|
+
async function experimentalCmd() {
|
|
3086
|
+
const sub = args[1];
|
|
3087
|
+
if (!sub || !EXPERIMENTAL_CMDS.has(sub)) {
|
|
3088
|
+
console.error(
|
|
3089
|
+
"usage: oats experimental <dress|spawn|segments|mind> [options]\n" +
|
|
3090
|
+
"EXPERIMENTAL tools over the turn record — unproven by design; see packages/experimental/README.md",
|
|
3091
|
+
);
|
|
3092
|
+
process.exit(sub === undefined ? 0 : 2);
|
|
3093
|
+
}
|
|
3094
|
+
const url = new URL(`../packages/experimental/bin/${sub}.mjs`, import.meta.url);
|
|
3095
|
+
if (!existsSync(url)) {
|
|
3096
|
+
die(
|
|
3097
|
+
`experimental tools ship only in the oats repo checkout, not in the published package — clone github.com/awebai/oats and run \`oats experimental ${sub}\` from it`,
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
process.argv.splice(2, 2);
|
|
3101
|
+
await import(url);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// ---------- main ----------
|
|
3105
|
+
// Typed config-shape failures are DEPLOYMENT state the operator can fix, not
|
|
3106
|
+
// kernel bugs: an unsafe mapping key anywhere in the visible config chain is
|
|
3107
|
+
// raised by the readers, which every command walks before it can do anything.
|
|
3108
|
+
// Without this boundary `oats doctor`, `oats use` and every --json mode printed a
|
|
3109
|
+
// raw Node stack with empty stdout — no code, no envelope, nothing to act on.
|
|
3110
|
+
// Deliberately narrow: only codes with a defined rendering are caught here;
|
|
3111
|
+
// anything else still crashes loudly.
|
|
3112
|
+
//
|
|
3113
|
+
// The dispatch chain below is deliberately NOT re-indented into this try block:
|
|
3114
|
+
// keeping it at column 0 makes the whole command table one reviewable diff of
|
|
3115
|
+
// added lines rather than ~150 lines of pure whitespace churn, and keeps `git
|
|
3116
|
+
// blame` pointing at the commit that last changed each command.
|
|
3117
|
+
const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
|
|
3118
|
+
try {
|
|
3119
|
+
if (cmd === "doctor") {
|
|
3120
|
+
const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
3121
|
+
args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
|
|
3122
|
+
}
|
|
3123
|
+
else if (cmd === "use") use();
|
|
3124
|
+
else if (cmd === "update") { const t = args[1] && !args[1].startsWith("--") ? args[1] : undefined; t ? updatePackageCmd(t) : updateCmd(); }
|
|
3125
|
+
else if (cmd === "type") typeCmd();
|
|
3126
|
+
else if (cmd === "inject") injectCmd();
|
|
3127
|
+
else if (cmd === "install") install();
|
|
3128
|
+
else if (cmd === "config") configCmd();
|
|
3129
|
+
else if (cmd === "trust") trust();
|
|
3130
|
+
else if (cmd === "list") listCmd();
|
|
3131
|
+
else if (cmd === "remove") removeCmd();
|
|
3132
|
+
else if (cmd === "migrate") migrateCmd();
|
|
3133
|
+
else if (cmd === "root") console.log(resolve(new URL("..", import.meta.url).pathname));
|
|
3134
|
+
else if (cmd === "init") init();
|
|
3135
|
+
else if (cmd === "status") status();
|
|
3136
|
+
else if (cmd === "pane") await paneCmd();
|
|
3137
|
+
else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
|
|
3138
|
+
// Same rule as the inner catch: a typed CLI failure surfaces with its own code
|
|
3139
|
+
// through the shared boundary, never re-badged as a spawn-mechanism failure.
|
|
3140
|
+
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; } }
|
|
3141
|
+
else if (cmd === "retire") retireCmd();
|
|
3142
|
+
else if (cmd === "create") createCmd();
|
|
3143
|
+
else if (cmd === "capture" || cmd === "recall" || cmd === "setup") await recordCmd(cmd);
|
|
3144
|
+
else if (cmd === "experimental") await experimentalCmd();
|
|
3145
|
+
// `!HELP_WORDS.has(cmd)`: usage NEVER depends on deployment state. `help` is a
|
|
3146
|
+
// word, so without this it reaches the capability dispatch, which resolves the
|
|
3147
|
+
// config chain and reads every lock in it — and a scope whose lock the kernel
|
|
3148
|
+
// refuses could then not print its own usage, which is exactly when you need it.
|
|
3149
|
+
else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && capabilityCommand()) { /* dispatched */ }
|
|
3150
|
+
// No matching kernel command or capability namespace: in --json mode the help
|
|
3151
|
+
// text must NOT contaminate stdout — still one envelope object, nonzero exit.
|
|
3152
|
+
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`);
|
|
3153
|
+
else {
|
|
3154
|
+
console.log(`oats — Open Agent Team Specification
|
|
3155
|
+
|
|
3156
|
+
Usage:
|
|
3157
|
+
oats version [--json] kernel version; --json emits the
|
|
3158
|
+
Desktop CLI API v1 probe payload
|
|
3159
|
+
oats status [--json] agents, souls, running instances
|
|
3160
|
+
oats status --team [--json] whole-team roster across the team scope's repos
|
|
3161
|
+
oats create <name> [--local] create an agent soul; --local = full
|
|
3162
|
+
[--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
|
|
3163
|
+
[--work <mode>] [--runtime pi|claude] gitignored; same memory + lifecycle)
|
|
3164
|
+
[--model <m>] [--instructions-file <f>]
|
|
3165
|
+
oats spawn <agent> [--task <text>] spawn an instance (tmux; --no-launch
|
|
3166
|
+
[--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
|
|
3167
|
+
[--parent <instance>] --def-file creates a local agent;
|
|
3168
|
+
[--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
|
|
3169
|
+
[--relative-to <instance>] new instance to an existing one; --parent X
|
|
3170
|
+
[--relative-root <agents-root>] disambiguates same-named team anchors
|
|
3171
|
+
[--work worktree|checkout|attached|workspace] = sugar for --relative-to X --relation
|
|
3172
|
+
[--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
|
|
3173
|
+
[--instructions-file <f>|--def-file <f>] [--no-launch] [--json]
|
|
3174
|
+
with team: declared, unknown local souls
|
|
3175
|
+
resolve across the team scope's repos
|
|
3176
|
+
oats retire <instance> [--force] retire an instance (window, hooks,
|
|
3177
|
+
[--self] [--delete-branch] worktree, home); --self = retire the
|
|
3178
|
+
[--keep-dir] [--json] CALLING instance (delayed window kill)
|
|
3179
|
+
oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
|
|
3180
|
+
--soul shows final composed AGENTS.md
|
|
3181
|
+
oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
|
|
3182
|
+
optionally run the update; then run oats doctor
|
|
3183
|
+
oats install [<source>] [--dir <d>] acquire + exact-lock a package closure
|
|
3184
|
+
(git:host/org/repo@ref[#<path>], git URL,
|
|
3185
|
+
local path, official catalog id) or a legacy
|
|
3186
|
+
marketplace capability; never activates
|
|
3187
|
+
#<path> selects the contained package root
|
|
3188
|
+
(default oats-package; #. = repository root;
|
|
3189
|
+
local paths are always exact directories)
|
|
3190
|
+
[--recursive] [--no-requirements] bare \`oats install\` exactly restores this
|
|
3191
|
+
[--accept-requirement <cmd> ...] chain's locked packages + capabilities; at a
|
|
3192
|
+
[--json] team: scope (or with --recursive) it reconciles
|
|
3193
|
+
the whole workspace — descendant scopes restore
|
|
3194
|
+
once in path order (pruned discovery), then the
|
|
3195
|
+
host-requirement consent gate runs;
|
|
3196
|
+
--no-requirements = package-only (CI);
|
|
3197
|
+
non-interactive runs never install host tools
|
|
3198
|
+
unless each requirement is named explicitly;
|
|
3199
|
+
--json = one envelope (failures carry the full
|
|
3200
|
+
report under error.details)
|
|
3201
|
+
oats list [--dir <d>] [--json] installed packages, exported capabilities,
|
|
3202
|
+
scopes, trust state
|
|
3203
|
+
oats update <package> [--dir <d>] transactional package update: temp fetch,
|
|
3204
|
+
closure validation, diff, lock replace,
|
|
3205
|
+
all capability approvals invalidated
|
|
3206
|
+
oats remove <package> [--dir <d>] remove a package (refuses while config or
|
|
3207
|
+
dependent packages reference it)
|
|
3208
|
+
oats migrate [--dry-run] [--dir <d>] map this scope's v1 capability locks to
|
|
3209
|
+
package locks (preserves config activation)
|
|
3210
|
+
oats migrate --official [--recursive] guided upgrade of 0.18 bundled official
|
|
3211
|
+
[--dry-run] [--dir <d>] [--json] capabilities to official packages: plans every
|
|
3212
|
+
visible lock-owning scope first, applies each
|
|
3213
|
+
transactionally, keeps custom/owned entries
|
|
3214
|
+
untouched, and prints the exact trust/install
|
|
3215
|
+
follow-up (held when the catalog cannot map yet)
|
|
3216
|
+
oats migrate --from-oas [--recursive] convert a pre-rename OAS deployment in place:
|
|
3217
|
+
[--dry-run] [--dir <d>] [--json] renames oas-* files, the oas: config key and
|
|
3218
|
+
capability ids, then chains the guided package
|
|
3219
|
+
conversion — one transaction per scope, any
|
|
3220
|
+
failure restores the original OAS bytes
|
|
3221
|
+
oats config diff [--config <template>] three-way report: your config vs the recorded
|
|
3222
|
+
[--dir <d>] [--json] adopted base vs the template in the current exact
|
|
3223
|
+
lock — reports only, never writes; the adopted
|
|
3224
|
+
base supplies the package/template defaults
|
|
3225
|
+
oats config sync [--accept <r>=local|package] apply the template's changes to your config,
|
|
3226
|
+
[--dir <d>] [--json] region by region, preserving every untouched local
|
|
3227
|
+
byte, comment and ordering; local-only edits stay;
|
|
3228
|
+
conflicts need an explicit --accept and are never
|
|
3229
|
+
chosen for you; advances the recorded base
|
|
3230
|
+
oats config sync --reset --yes replace your config with the template verbatim;
|
|
3231
|
+
[--config <template>] [--dir <d>] previews every local change it discards, refuses
|
|
3232
|
+
[--json] without --yes, and keeps a recoverable .bak
|
|
3233
|
+
oats config adopt <package> switch to another installed package's template,
|
|
3234
|
+
[--config <template>] [--accept ...] rebasing your one local config; exactly one adopted
|
|
3235
|
+
[--dir <d>] [--json] base survives, and a failed switch changes nothing
|
|
3236
|
+
oats trust <capability> [--dir <dir>] approve that capability's commands, hooks, and
|
|
3237
|
+
launch-environment authority at
|
|
3238
|
+
the provider package's exact integrity
|
|
3239
|
+
oats trust <package> --all-capabilities explicit bulk approval with a full
|
|
3240
|
+
executable-surface summary
|
|
3241
|
+
oats use <capability> activate for one config-owned target
|
|
3242
|
+
[--global|--type <t>|--soul <s>] (--global is default); --disable excludes
|
|
3243
|
+
[--disable] [--settings k=v [k2=v2 ...]] [--dir <d>]
|
|
3244
|
+
oats use none --layer <layer> explicitly disable a fundamental layer
|
|
3245
|
+
oats type add <name> [--description <d>] declare an agent type (family) in config;
|
|
3246
|
+
oats type list souls join via create --type / soul.yaml
|
|
3247
|
+
oats inject eject <cap|work-mode|oats> copy a packaged injection to the conventional
|
|
3248
|
+
[--dir <d>] .agents/injections/ path and set injection-override
|
|
3249
|
+
oats init [--raw] [--dir <dir>] [--json] create an oats-config.yaml here. Fundamental
|
|
3250
|
+
[--knowledge <id|none>] layers are filled from what is already at this
|
|
3251
|
+
[--messaging <id|none>] scope, else acquired from the official package
|
|
3252
|
+
[--tasks <id|none>] that supplies them — capabilities materialize
|
|
3253
|
+
[--tmux-mouse|--no-tmux-mouse] flat, executable surfaces stay untrusted, and
|
|
3254
|
+
the whole run rolls back on any failure.
|
|
3255
|
+
[--package <id|path|git-url>] instead: adopt one config TEMPLATE from a package
|
|
3256
|
+
[--config <template>] as your own local config and record the exact
|
|
3257
|
+
adopted base (named template, else the marked
|
|
3258
|
+
default, else the only one).
|
|
3259
|
+
[--template <name|path|git-url>] instead: seed from a template config (named via an
|
|
3260
|
+
outer templates: map, a local file, or a git repo's
|
|
3261
|
+
default-branch oats-config.yaml).
|
|
3262
|
+
Every form refuses to overwrite an existing config;
|
|
3263
|
+
--json = exactly one result envelope, noninteractive.
|
|
3264
|
+
oats root print this package's install root
|
|
3265
|
+
(adapters resolve the kernel from it)
|
|
3266
|
+
|
|
3267
|
+
The turn record (core — every conversation captured, searchable, replicated):
|
|
3268
|
+
oats capture [--watch|--status] land Claude Code/pi/codex sessions and aw
|
|
3269
|
+
[--owner <name>] [--root <dir>] client logs in the record; reconciliation
|
|
3270
|
+
is the capture
|
|
3271
|
+
oats recall [--kind k] [--thread t] search the whole record — mail, chat,
|
|
3272
|
+
[--from f] [--show id] <query> sessions — with exact turn provenance
|
|
3273
|
+
oats setup [--owner <name>] [--dry-run] install capture hooks + background watcher
|
|
3274
|
+
[--no-service] [--no-hooks] (launchd/systemd), then run the first pass
|
|
3275
|
+
|
|
3276
|
+
oats experimental <dress|spawn|segments|mind> EXPERIMENTAL tools over the record —
|
|
3277
|
+
selection and agent synthesis; unproven by
|
|
3278
|
+
design, repo checkout only; see
|
|
3279
|
+
packages/experimental/README.md
|
|
3280
|
+
|
|
3281
|
+
oats <namespace> <command> [args…] run an operational command only when its
|
|
3282
|
+
capability is active (e.g. oats okf harvest)
|
|
3283
|
+
|
|
3284
|
+
Layers: ${LAYERS.join(", ")}. Level detection: ~ → laptop, .git → repo, else workspace.`);
|
|
3285
|
+
process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
|
|
3286
|
+
}
|
|
3287
|
+
} catch (e) {
|
|
3288
|
+
if (!TYPED_CLI_FAILURES.has(e?.code)) throw e;
|
|
3289
|
+
// Same two renderings as every other typed failure: one envelope on stdout in
|
|
3290
|
+
// --json mode, one `oats: <message>` line on stderr otherwise. The message
|
|
3291
|
+
// already names the offending file — the readers re-raise it with one.
|
|
3292
|
+
if (JSON_MODE) jsonFail(e.code, e.message);
|
|
3293
|
+
die(e.message);
|
|
3294
|
+
}
|