@awebai/oats 0.24.12 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +936 -2822
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +386 -6
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +233 -0
- package/docs/release-notes/v0.24.13.md +51 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +194 -34
- package/lib/workspace.mjs +635 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
package/lib/core.mjs
CHANGED
|
@@ -45,6 +45,14 @@ import { killGroup } from "./process-group.mjs";
|
|
|
45
45
|
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
|
|
46
46
|
|
|
47
47
|
import { oatsError } from "./errors.mjs";
|
|
48
|
+
async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
|
|
49
|
+
// Capability rows for a PREPARED spawn (workspace model): the one function that
|
|
50
|
+
// turns a Resolution's modules into the row shape hooks/environment/requirements/
|
|
51
|
+
// retirement consume. Called twice per spawn — PLANNED before the home exists
|
|
52
|
+
// (manifest, settings, origin, trust; no skills/inject since the copies are not
|
|
53
|
+
// there yet) and REBUILT after materialize against what actually landed. Static
|
|
54
|
+
// import: instance-resolution.mjs does not depend on core.mjs (no cycle).
|
|
55
|
+
import { toCapabilityRows } from "./instance-resolution.mjs";
|
|
48
56
|
const CAPTURED_NATIVE_START = Symbol("captured-native-start");
|
|
49
57
|
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
50
58
|
import { loadCapturedAction } from "./captured-dispatch.mjs";
|
|
@@ -585,9 +593,10 @@ export function canonicalAgentsRoot(root) { return canonicalDeploymentPath(root)
|
|
|
585
593
|
export function ensureRoot(cwd) {
|
|
586
594
|
const root = findRoot(cwd);
|
|
587
595
|
if (!root) {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
596
|
+
const from = resolve(cwd ?? process.cwd());
|
|
597
|
+
throw oatsError("E_NO_DEPLOYMENT",
|
|
598
|
+
`no deployment found walking up from ${from}: no agents/ or local-agents/ directory — create one (mkdir agents, or \`oats create <name> --local\`), run from the deployment (where oats-local.yaml lives), or set PI_AGENTS_ROOT`,
|
|
599
|
+
{ from, looked: ["agents/", "local-agents/"] });
|
|
591
600
|
}
|
|
592
601
|
// Deployment root ≠ invocation CWD: homes always land in the primary checkout.
|
|
593
602
|
return canonicalAgentsRoot(root);
|
|
@@ -1104,7 +1113,12 @@ const PROCESS_BOOTSTRAP_PREFIXES = [
|
|
|
1104
1113
|
function validateCapabilityManifest(m, mf) {
|
|
1105
1114
|
const id = m.capability;
|
|
1106
1115
|
if (!id) throw new Error(`capability manifest needs "capability": ${mf}`);
|
|
1107
|
-
|
|
1116
|
+
// Workspace model (contract §2): a capability name is `^[a-z0-9][a-z0-9._-]*$`
|
|
1117
|
+
// — a member capability is `nw-deploy`, a package one `oats.okf`. The classic
|
|
1118
|
+
// "must be namespaced" rule kept marketplace ids unambiguous; the workspace
|
|
1119
|
+
// has one flat name space per organisation instead (duplicate → E_CAPABILITY_AMBIGUOUS
|
|
1120
|
+
// at resolution), so the grammar is the only requirement.
|
|
1121
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`capability ID must match ^[a-z0-9][a-z0-9._-]*$: "${id}" (${mf})`);
|
|
1108
1122
|
if (m.retirement !== undefined) {
|
|
1109
1123
|
if (!isPlainObject(m.retirement) || !isPlainObject(m.retirement.disposable)) throw new Error(`capability ${id} manifest retirement must contain a disposable map`);
|
|
1110
1124
|
const unknown = Object.keys(m.retirement).filter((key) => key !== "disposable");
|
|
@@ -1620,6 +1634,31 @@ export function capabilityManifests(startDir) {
|
|
|
1620
1634
|
// Dot-prefixed entries are transaction staging, never installed content.
|
|
1621
1635
|
for (const e of readdirSync(dir, { withFileTypes: true })) if (e.isDirectory() && !e.name.startsWith(".")) add(loadManifestAt(join(dir, e.name), origin));
|
|
1622
1636
|
};
|
|
1637
|
+
// Workspace model: an INSTANCE HOME carries its own materialized modules
|
|
1638
|
+
// (<home>/.oats/modules/<cap>/, recorded in instance.json.modules). They are
|
|
1639
|
+
// the whole capability set of that instance — nothing from any config chain
|
|
1640
|
+
// applies to it — so answer from them and stop. Trust: membership (member
|
|
1641
|
+
// modules) or the per-version approval sync recorded (package modules); both
|
|
1642
|
+
// were checked before materialization, and the copy is the instance's own.
|
|
1643
|
+
// The deployment's module STORE (<deployment>/.oats/modules/<cap>@<commit>/ —
|
|
1644
|
+
// where a package capability agent's tree was fetched by the workspace
|
|
1645
|
+
// resolver): a store entry answers with its own manifest, trusted like an
|
|
1646
|
+
// instance module (the lock approved the package before the fetch).
|
|
1647
|
+
if (startDir && basename(dirname(startDir)) === "modules" && basename(dirname(dirname(startDir))) === ".oats"
|
|
1648
|
+
&& existsSync(join(dirname(dirname(dirname(startDir))), "oats-local.yaml")) && existsSync(join(startDir, "oats.json"))) {
|
|
1649
|
+
const m = loadManifestAt(startDir, `module:${startDir}`);
|
|
1650
|
+
if (m) add(m);
|
|
1651
|
+
return out;
|
|
1652
|
+
}
|
|
1653
|
+
const modulesOf = instanceModulesRoot(startDir);
|
|
1654
|
+
if (modulesOf) {
|
|
1655
|
+
for (const [name, rec] of Object.entries(modulesOf.modules)) {
|
|
1656
|
+
if (!isMaterializedCapabilityId(name) || name === "__proto__") continue;
|
|
1657
|
+
const m = loadManifestAt(join(modulesOf.root, name), `module:${modulesOf.home}`);
|
|
1658
|
+
if (m) { m._module = rec; add(m); }
|
|
1659
|
+
}
|
|
1660
|
+
return out;
|
|
1661
|
+
}
|
|
1623
1662
|
if (startDir) {
|
|
1624
1663
|
for (const cfg of [...configChain(startDir)].reverse()) {
|
|
1625
1664
|
const store = join(cfg._level, CAPABILITIES_DIRNAME);
|
|
@@ -1676,6 +1715,29 @@ export function capabilityManifests(startDir) {
|
|
|
1676
1715
|
export function capabilityManifest(name, startDir) {
|
|
1677
1716
|
return capabilityManifests(startDir)[name];
|
|
1678
1717
|
}
|
|
1718
|
+
/** When `dir` is (or is inside) an instance home materialized under the workspace
|
|
1719
|
+
* model, → { home, root: <home>/.oats/modules, modules: instance.json.modules }; else null.
|
|
1720
|
+
* Walks up from `dir` to the nearest instance.json carrying a `modules` object. */
|
|
1721
|
+
export function instanceModulesRoot(dir) {
|
|
1722
|
+
if (!dir) return null;
|
|
1723
|
+
let cur = resolve(dir);
|
|
1724
|
+
for (let i = 0; i < 6; i++) {
|
|
1725
|
+
const metaFile = join(cur, "instance.json");
|
|
1726
|
+
if (existsSync(metaFile)) {
|
|
1727
|
+
let meta;
|
|
1728
|
+
try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch { return null; }
|
|
1729
|
+
if (meta && typeof meta === "object" && meta.modules && typeof meta.modules === "object" && !Array.isArray(meta.modules)) {
|
|
1730
|
+
const root = join(cur, ".oats", "modules");
|
|
1731
|
+
return existsSync(root) ? { home: cur, root, modules: meta.modules } : null;
|
|
1732
|
+
}
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
const parent = dirname(cur);
|
|
1736
|
+
if (parent === cur) break;
|
|
1737
|
+
cur = parent;
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1679
1741
|
|
|
1680
1742
|
/** Remove source-control metadata from the ROOT of a managed artifact.
|
|
1681
1743
|
*
|
|
@@ -1923,6 +1985,14 @@ export function capabilityTrust(a, b) {
|
|
|
1923
1985
|
function manifestTrust(manifest, startDir, requireExecutableApproval = true) {
|
|
1924
1986
|
if (!manifest) return { trusted: false, reason: "manifest missing" };
|
|
1925
1987
|
const origin = String(manifest._origin);
|
|
1988
|
+
// Workspace model: a materialized module is trusted by construction — a
|
|
1989
|
+
// member capability by membership (decision 2), a package capability by the
|
|
1990
|
+
// per-version approval recorded in the lock before sync let it materialize
|
|
1991
|
+
// (E_PACKAGE_UNAPPROVED otherwise). The copy is the instance's own.
|
|
1992
|
+
if (origin.startsWith("module:")) {
|
|
1993
|
+
const from = manifest._module?.from;
|
|
1994
|
+
return { trusted: true, module: true, reason: from?.kind === "package" ? `package ${from.package} v${from.version} approved at sync` : `workspace member ${from?.repoKey ?? "?"}`, package: from?.kind === "package" ? from.package : undefined };
|
|
1995
|
+
}
|
|
1926
1996
|
if (origin.startsWith("owned:") || (!requireExecutableApproval && origin.startsWith("path:"))) return { trusted: true, configOwned: true };
|
|
1927
1997
|
const executable = requireExecutableApproval && hasExecutableSurface(manifest);
|
|
1928
1998
|
if (manifest._package) {
|
|
@@ -2449,7 +2519,14 @@ export function parseLockBytesStrict(bytes, { file = "<oats-lock.json>", limits
|
|
|
2449
2519
|
|
|
2450
2520
|
export function parseLockFileStrict(file) {
|
|
2451
2521
|
if (!existsSync(file)) return null;
|
|
2452
|
-
|
|
2522
|
+
const bytes = readFileSync(file);
|
|
2523
|
+
// A workspace-model lock (lockfileVersion 3, lib/packages.mjs) annotates no
|
|
2524
|
+
// installed tier — there is none. The classic chain treats it as "no v1 lock
|
|
2525
|
+
// here"; the v3 readers (sync, spawn, doctor) own it. Sniffing the version is
|
|
2526
|
+
// deliberately tolerant: a broken file still reaches the strict legacy codec
|
|
2527
|
+
// so its typed error is preserved for doctor.
|
|
2528
|
+
try { const v = JSON.parse(bytes.toString("utf8"))?.lockfileVersion; if (v === 3) return null; } catch { /* strict codec reports it */ }
|
|
2529
|
+
return parseLockBytesStrict(bytes, { file });
|
|
2453
2530
|
}
|
|
2454
2531
|
|
|
2455
2532
|
/** Every lock-owning scope visible from a directory, outermost → innermost.
|
|
@@ -4146,14 +4223,69 @@ export function isOatsWorkspace(startDir) {
|
|
|
4146
4223
|
return false;
|
|
4147
4224
|
}
|
|
4148
4225
|
|
|
4226
|
+
/** Capability rows for a prepared spawn BEFORE its home exists: the same shape
|
|
4227
|
+
* `toCapabilityRows` builds (manifest, settings, origin, provenance, trust, hooks,
|
|
4228
|
+
* environment) with every home-relative path resolved against a placeholder that
|
|
4229
|
+
* cannot exist, so `skills` is [] and `inject` is undefined until materialize has
|
|
4230
|
+
* copied the module. `planned: true` marks the row so preflight knows those two
|
|
4231
|
+
* fields are deferred (the copies are digest-verified by materialize itself). */
|
|
4232
|
+
const PLANNED_HOME_PLACEHOLDER = join(sep, "oats-planned-home-does-not-exist", "placeholder");
|
|
4233
|
+
function plannedCapabilityRows(resolution) {
|
|
4234
|
+
return toCapabilityRows(resolution, PLANNED_HOME_PLACEHOLDER).map((row) => ({ ...row, level: undefined, dir: undefined, skills: [], inject: undefined, planned: true,
|
|
4235
|
+
// Declared command text only (manifest-relative; never executed from a planned row).
|
|
4236
|
+
hooks: Object.fromEntries(Object.entries(row.hooks || {}).filter(([event]) => APPROVED_HOOKS.has(event)).map(([event, h]) => [event, typeof h === "string" ? h : h?.command])) }));
|
|
4237
|
+
}
|
|
4238
|
+
/** Turn a materialized row's hook declarations (`{ command, cwd, required }` per
|
|
4239
|
+
* event, as `toCapabilityRows` records them) into the SHELL STRINGS the kernel's
|
|
4240
|
+
* hook runner and the retire path (via instance.json#capabilityRuntime) execute:
|
|
4241
|
+
* `node <abs script> <args>`, exactly as `manifestHookCommands` renders a classic
|
|
4242
|
+
* capability. The script must exist inside the module's copy under
|
|
4243
|
+
* <home>/.oats/modules/<cap>/ — a path escaping it is a manifest defect. Only
|
|
4244
|
+
* the events the kernel approves (soul-scaffold, spawn, retire, launch) are kept. */
|
|
4245
|
+
function materializedHookCommands(row, home) {
|
|
4246
|
+
const out = {};
|
|
4247
|
+
const moduleDir = row.dir || join(home, ".oats", "modules", row.id);
|
|
4248
|
+
for (const [event, spec] of Object.entries(row.hooks || {})) {
|
|
4249
|
+
if (!APPROVED_HOOKS.has(event)) continue;
|
|
4250
|
+
const command = typeof spec === "string" ? spec : spec?.command;
|
|
4251
|
+
if (typeof command !== "string" || !command.trim()) continue;
|
|
4252
|
+
if (/^node\s+'/.test(command)) { out[event] = command; continue; } // already rendered
|
|
4253
|
+
const [script, ...args] = command.trim().split(/\s+/);
|
|
4254
|
+
const abs = join(moduleDir, script);
|
|
4255
|
+
if (!existsSync(abs)) throw oatsError("E_CAPABILITY_RESOURCE_MISSING", `capability ${row.id} declares hook ${event} as ${JSON.stringify(command)}, but ${abs} is not in its materialized copy`);
|
|
4256
|
+
const root = realpathSync(moduleDir); const target = realpathSync(abs); const fromRoot = relative(root, target);
|
|
4257
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) throw new Error(`capability ${row.id} hook ${event} path escapes its module copy: ${script}`);
|
|
4258
|
+
out[event] = ["node", shq(abs), ...args].join(" ");
|
|
4259
|
+
}
|
|
4260
|
+
return out;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4149
4263
|
/** Compose, but never mutate, an instance instruction view from canonical soul instructions.
|
|
4150
4264
|
* `kind` tunes composition: "local" adds the packaged local-soul briefing;
|
|
4151
4265
|
* "capability" suppresses the knowledge layer's injection (ephemeral service
|
|
4152
4266
|
* agents — reviewers, harvesters — carry no episodic memory by design). */
|
|
4153
|
-
export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode, kind) {
|
|
4267
|
+
export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode, kind, prepared = undefined) {
|
|
4154
4268
|
const agentsMd = join(soulDir, "AGENTS.md");
|
|
4155
4269
|
if (!existsSync(agentsMd)) throw new Error(`canonical soul instructions missing: ${agentsMd}`);
|
|
4270
|
+
// Two chains meet here. With `prepared` (workspace model: discover → resolve over
|
|
4271
|
+
// remotes; materialized into the home by the caller) the capability set is the
|
|
4272
|
+
// resolution's modules and the classic config chain supplies ONLY machine-level
|
|
4273
|
+
// knobs (yolo, launch configs, kernel/work-mode injects). WITHOUT `prepared`
|
|
4274
|
+
// (a bare agents root, schedules, tests) the classic chain still activates
|
|
4275
|
+
// capabilities from oats-config.yaml exactly as before — that path is scheduled
|
|
4276
|
+
// to fold into the one prepared pipeline, not silently disabled.
|
|
4156
4277
|
const resolved = resolveOatsConfig(contextDir, soulName);
|
|
4278
|
+
if (prepared) {
|
|
4279
|
+
// PLANNED rows: manifest/settings/origin/trust are known now; skills and
|
|
4280
|
+
// inject paths point into the home, which does not exist yet, so they resolve
|
|
4281
|
+
// to nothing here and are rebuilt by spawnBody once materialize has landed.
|
|
4282
|
+
// `prepared.capabilityRows` (the CLI passes []) is honoured only when non-empty.
|
|
4283
|
+
resolved.capabilities = Array.isArray(prepared.capabilityRows) && prepared.capabilityRows.length
|
|
4284
|
+
? prepared.capabilityRows
|
|
4285
|
+
: plannedCapabilityRows(prepared.resolution);
|
|
4286
|
+
resolved.workspace = { key: prepared.discovery?.key ?? null, commit: prepared.discovery?.commit ?? null, standalone: prepared.discovery?.standalone === true, revision: prepared.resolution.revision, team: prepared.resolution.soul?.team ?? null, slots: prepared.resolution.slots };
|
|
4287
|
+
resolved.layers = Object.fromEntries(Object.entries(prepared.resolution.slots || {}).map(([slot, mod]) => [slot, mod ? { capability: mod } : null]));
|
|
4288
|
+
}
|
|
4157
4289
|
const wanted = [];
|
|
4158
4290
|
const declaredOperations = declaredOperationalCapabilities(soulDir);
|
|
4159
4291
|
const oatsCoreDeclared = declaredOperations.includes("oats.core");
|
|
@@ -4223,7 +4355,7 @@ function hasSkillDoc(dir) {
|
|
|
4223
4355
|
* Installed-but-inactive capabilities are absent from `resolved.capabilities`
|
|
4224
4356
|
* and so contribute nothing here, by construction.
|
|
4225
4357
|
*/
|
|
4226
|
-
export function planInstanceResources({ resolved, soulDir, agent, contextDir, composition }) {
|
|
4358
|
+
export function planInstanceResources({ resolved, soulDir, agent, contextDir, composition, prepared }) {
|
|
4227
4359
|
const expected = [];
|
|
4228
4360
|
const missing = [];
|
|
4229
4361
|
/** `declares` marks a tree the manifest PROMISED: it must resolve AND yield at
|
|
@@ -4238,7 +4370,10 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
|
|
|
4238
4370
|
}
|
|
4239
4371
|
};
|
|
4240
4372
|
|
|
4241
|
-
|
|
4373
|
+
// Workspace model: operational knowledge is a capability (oats.core, from a
|
|
4374
|
+
// package) resolved like any other; the kernel bundles no skills for a prepared
|
|
4375
|
+
// spawn. The classic path keeps the bundled trio for souls that declare nothing.
|
|
4376
|
+
if (!prepared) for (const { path } of legacyOperationalSkills(soulDir)) {
|
|
4242
4377
|
add({ type: "skill-tree", source: "kernel", declared: basename(path), path: existsSync(path) ? path : undefined });
|
|
4243
4378
|
}
|
|
4244
4379
|
const soulSkills = soulDir && join(soulDir, "skills");
|
|
@@ -4246,6 +4381,21 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
|
|
|
4246
4381
|
if (soulSkills && existsSync(soulSkills)) add({ type: "skill-tree", source: "soul", declared: soulSkills, path: soulSkills }, { declares: false });
|
|
4247
4382
|
|
|
4248
4383
|
for (const cap of resolved.capabilities || []) {
|
|
4384
|
+
if (cap.planned === true) {
|
|
4385
|
+
// Workspace model, before the home exists: the module's declared skills and
|
|
4386
|
+
// inject are promised by its manifest and will be copied WHOLE (and digest-
|
|
4387
|
+
// verified) by materialize. They are recorded as expected here — with their
|
|
4388
|
+
// future home-relative paths — and asserted against the landed copies in
|
|
4389
|
+
// spawnBody's EXPECTED == MATERIALIZED check, not against the filesystem now.
|
|
4390
|
+
for (const s of cap.skillsDeclared || []) {
|
|
4391
|
+
const declared = typeof s === "string" ? s : s?.declared;
|
|
4392
|
+
if (typeof declared !== "string" || !declared) continue;
|
|
4393
|
+
expected.push({ type: "skill-tree", source: cap.id, declared, path: undefined, entries: [], deferred: "materialize", origin: cap.origin, module: cap.id });
|
|
4394
|
+
}
|
|
4395
|
+
const intentionallyDroppedPlanned = agent?.kind === "capability" && cap.layer === "knowledge";
|
|
4396
|
+
if (cap.injectDeclared && !intentionallyDroppedPlanned) expected.push({ type: "injection", source: cap.id, declared: cap.injectDeclared, path: undefined, deferred: "materialize", origin: cap.origin, module: cap.id });
|
|
4397
|
+
continue;
|
|
4398
|
+
}
|
|
4249
4399
|
for (const s of cap.skillsDeclared || []) {
|
|
4250
4400
|
add({ type: "skill-tree", source: cap.id, declared: s.declared, path: s.path, origin: cap.origin, level: cap.level });
|
|
4251
4401
|
}
|
|
@@ -4268,7 +4418,9 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
|
|
|
4268
4418
|
// A capability-defined agent always carries its own capability's skills,
|
|
4269
4419
|
// regardless of config targeting, so they are expected for it too.
|
|
4270
4420
|
if (agent?.kind === "capability" && agent.capability && !(resolved.capabilities || []).some((c) => c.id === agent.capability)) {
|
|
4271
|
-
|
|
4421
|
+
// A module-resolved capability agent (workspace model) reads its provider from
|
|
4422
|
+
// the parent home's materialized copy, never from a config chain.
|
|
4423
|
+
for (const s of capabilityDeclaredSkills(agent.capability, agent._manifestSource ?? contextDir)) {
|
|
4272
4424
|
add({ type: "skill-tree", source: agent.capability, declared: s.declared, path: s.path });
|
|
4273
4425
|
}
|
|
4274
4426
|
}
|
|
@@ -4287,9 +4439,12 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
|
|
|
4287
4439
|
const inactiveOps = declaredOps.filter((id) => !(resolved.capabilities || []).some((c) => c.id === id));
|
|
4288
4440
|
if (inactiveOps.length) {
|
|
4289
4441
|
const soulName = agent?.name ?? basename(dirname(soulDir));
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4442
|
+
// Workspace model: capabilities are not installed or activated per soul; the
|
|
4443
|
+
// deployment names its workspace (or standalone repo) in oats-local.yaml and
|
|
4444
|
+
// `oats sync` locks/approves the packages the souls draw from.
|
|
4445
|
+
const remedy = "add oats-local.yaml (workspace: <ref> or standalone: <ref>) beside agents/ and run oats sync";
|
|
4446
|
+
throw Object.assign(oatsError("E_REQUIREMENT_INACTIVE", `soul ${soulName} declares ${inactiveOps.join(", ")} in requires.capabilities, but ${inactiveOps.length === 1 ? "it is" : "they are"} not active for this soul in ${contextDir}; the declaration suppresses the kernel's legacy skills, so the instance would start with no operational curriculum.\n Remedy: ${remedy}${contextDir ? ` (here: ${join(contextDir, "oats-local.yaml")}, then \`oats sync --dir ${contextDir}\`)` : ""} — ${inactiveOps.join(", ")} then resolve${inactiveOps.length === 1 ? "s" : ""} from the workspace's packages (from: package).\n Or remove the declaration from ${join(soulDir, "soul.yaml")} to run without it.`),
|
|
4447
|
+
{ soul: soulName, capabilities: inactiveOps, context: contextDir, remedy, details: { soul: soulName, capabilities: inactiveOps, context: contextDir, remedy } });
|
|
4293
4448
|
}
|
|
4294
4449
|
|
|
4295
4450
|
// A capability that declares a REQUIRED hook it cannot execute must not spawn.
|
|
@@ -4546,19 +4701,6 @@ export function runtimePackageIdentity(runtime, spec) {
|
|
|
4546
4701
|
return mgr?.identity ? mgr.identity(spec) : packageSpecIdentity(spec);
|
|
4547
4702
|
}
|
|
4548
4703
|
|
|
4549
|
-
/** Is a runtime package actually USABLE — present, with a verified install
|
|
4550
|
-
* location? Deliberately ONE predicate rather than a presence check plus a
|
|
4551
|
-
* satisfaction check: the two would drift, and every caller means "is it really
|
|
4552
|
-
* there". A configured row with no install location, a location that does not
|
|
4553
|
-
* exist, or a settings-only answer we could not verify all count as NOT
|
|
4554
|
-
* installed, so requirement aggregation still offers to install it and
|
|
4555
|
-
* post-install verification cannot report success while it is still missing
|
|
4556
|
-
* (reviewer-14c38e8). `runtimePackageStatus` carries the detail for diagnostics. */
|
|
4557
|
-
export function runtimePackageInstalled(runtime, spec, env = process.env, opts = {}) {
|
|
4558
|
-
const st = runtimePackageStatus(runtime, spec, env, opts);
|
|
4559
|
-
return !!st.installed && !st.unverified && !st.missingFiles && !st.disabled;
|
|
4560
|
-
}
|
|
4561
|
-
|
|
4562
4704
|
/** Gate: a runtime package spec must be a plain source token — no shell syntax,
|
|
4563
4705
|
* whitespace, path traversal, or option-looking leading dash. Fail closed. */
|
|
4564
4706
|
export function safeRuntimePackageSpec(spec, runtime = "pi") {
|
|
@@ -5029,6 +5171,76 @@ export function findCapabilityAgent(contextDir, root, name) {
|
|
|
5029
5171
|
if (matchedFailures.length) throw matchedFailures[0];
|
|
5030
5172
|
return undefined;
|
|
5031
5173
|
}
|
|
5174
|
+
/**
|
|
5175
|
+
* Workspace model: a capability-defined agent (a package's `agents:` soul, e.g.
|
|
5176
|
+
* OKF's memory-harvest worker) resolves from a MATERIALIZED module — the copy the
|
|
5177
|
+
* requesting instance already carries under <home>/.oats/modules/<cap>/. Looks in
|
|
5178
|
+
* `anchorHome` first (the --parent / --relative-to instance), then every instance
|
|
5179
|
+
* home under `root`; the first module declaring an agent of that name wins.
|
|
5180
|
+
* → the same shape findCapabilityAgent returns, plus `_manifestSource` (the home
|
|
5181
|
+
* whose modules supplied it) so skill/inject lookups read that copy.
|
|
5182
|
+
*/
|
|
5183
|
+
export function findModuleCapabilityAgent(root, name, { anchorHome = null } = {}) {
|
|
5184
|
+
if (typeof name !== "string" || !name) return undefined;
|
|
5185
|
+
const homes = [];
|
|
5186
|
+
if (anchorHome) homes.push(anchorHome);
|
|
5187
|
+
if (root && existsSync(root)) {
|
|
5188
|
+
for (const a of readdirSync(root, { withFileTypes: true })) {
|
|
5189
|
+
if (!a.isDirectory() || a.name.startsWith(".")) continue;
|
|
5190
|
+
const inst = join(root, a.name, "instances");
|
|
5191
|
+
if (!existsSync(inst)) continue;
|
|
5192
|
+
for (const i of readdirSync(inst, { withFileTypes: true })) if (i.isDirectory() && !i.name.startsWith(".")) homes.push(join(inst, i.name));
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
5195
|
+
const seen = new Set();
|
|
5196
|
+
const failures = [];
|
|
5197
|
+
for (const home of homes) {
|
|
5198
|
+
const real = (() => { try { return realpathSync(home); } catch { return null; } })();
|
|
5199
|
+
if (!real || seen.has(real)) continue;
|
|
5200
|
+
seen.add(real);
|
|
5201
|
+
const modules = instanceModulesRoot(real);
|
|
5202
|
+
if (!modules) continue;
|
|
5203
|
+
for (const [id, manifest] of Object.entries(capabilityManifests(real))) {
|
|
5204
|
+
for (const rel of manifest?.agents || []) {
|
|
5205
|
+
let meta;
|
|
5206
|
+
try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
|
|
5207
|
+
if (!meta || meta.name !== name) continue;
|
|
5208
|
+
try {
|
|
5209
|
+
assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
|
|
5210
|
+
return {
|
|
5211
|
+
...meta.soul, name,
|
|
5212
|
+
kind: "capability", capability: id,
|
|
5213
|
+
_dir: join(localAgentsDirOf(root), name),
|
|
5214
|
+
_soulDir: meta.soulDir,
|
|
5215
|
+
_manifestSource: real,
|
|
5216
|
+
_module: manifest._module,
|
|
5217
|
+
};
|
|
5218
|
+
} catch (e) { failures.push(e); }
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
5221
|
+
}
|
|
5222
|
+
if (failures.length) throw failures[0];
|
|
5223
|
+
return undefined;
|
|
5224
|
+
}
|
|
5225
|
+
/**
|
|
5226
|
+
* A capability-defined agent from ONE capability directory (a package tree the
|
|
5227
|
+
* workspace resolver fetched into the deployment's module store). The manifest is
|
|
5228
|
+
* read from `dir`, trusted by construction (the lock approved the package), and
|
|
5229
|
+
* the agent record points its skill/inject lookups at that store entry.
|
|
5230
|
+
*/
|
|
5231
|
+
export function capabilityAgentFromDir(dir, name, root, { module = null } = {}) {
|
|
5232
|
+
const manifest = loadManifestAt(dir, `module:${dir}`);
|
|
5233
|
+
if (!manifest) return undefined;
|
|
5234
|
+
manifest._module = module;
|
|
5235
|
+
for (const rel of manifest.agents || []) {
|
|
5236
|
+
let meta;
|
|
5237
|
+
try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
|
|
5238
|
+
if (!meta || meta.name !== name) continue;
|
|
5239
|
+
assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
|
|
5240
|
+
return { ...meta.soul, name, kind: "capability", capability: manifest.capability, _dir: join(localAgentsDirOf(root), name), _soulDir: meta.soulDir, _manifestSource: dir, _manifest: manifest, _module: module };
|
|
5241
|
+
}
|
|
5242
|
+
return undefined;
|
|
5243
|
+
}
|
|
5032
5244
|
/** All capability-defined agents declared in a context (for status/errors).
|
|
5033
5245
|
* Invalid providers degrade independently; diagnostics is a non-enumerable
|
|
5034
5246
|
* array property so existing roster consumers keep the public array shape. */
|
|
@@ -5661,11 +5873,11 @@ export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env
|
|
|
5661
5873
|
* capability args; for claude/codex the `--` separator keeps them from
|
|
5662
5874
|
* swallowing the task, for pi they follow the task like capability args.
|
|
5663
5875
|
*
|
|
5664
|
-
* pi
|
|
5665
|
-
*
|
|
5666
|
-
*
|
|
5667
|
-
*
|
|
5668
|
-
* options because pi has no `--`. claude gets `--` before the prompt so a
|
|
5876
|
+
* pi starts NORMALLY (decision 13 of the workspace model): its own skill and
|
|
5877
|
+
* context discovery is left intact — the instance's copied capability skills
|
|
5878
|
+
* under <home>/.agents/skills are found from cwd=home like any repo's — and OATS
|
|
5879
|
+
* contributes only the composed AGENTS.md (--append-system-prompt). The task
|
|
5880
|
+
* positional goes ahead of contributed options because pi has no `--`. claude gets `--` before the prompt so a
|
|
5669
5881
|
* greedy contributed flag cannot eat it. codex keeps its native policy;
|
|
5670
5882
|
* yolo also trusts this generated home for the launch (projects=...). */
|
|
5671
5883
|
export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
@@ -5682,7 +5894,11 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
5682
5894
|
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
5683
5895
|
cmdline = `${shq(executable)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
5684
5896
|
} else {
|
|
5685
|
-
|
|
5897
|
+
// Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
|
|
5898
|
+
// .agents/skills up the tree, so the instance's copied capability skills are
|
|
5899
|
+
// found from cwd=home) and context files stay ambient. OATS contributes only
|
|
5900
|
+
// the composed instructions.
|
|
5901
|
+
cmdline = `${shq(executable)} --append-system-prompt ${shq(join(home, "AGENTS.md"))} --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${tail}`;
|
|
5686
5902
|
}
|
|
5687
5903
|
const hookEnv = recipe.hooks?.env || {};
|
|
5688
5904
|
const envTokens = Object.keys(hookEnv).sort().map((name) => `${name}=${shq(redact ? "<redacted>" : hookEnv[name])}`);
|
|
@@ -5874,7 +6090,34 @@ export function redactLaunchRecipe(recipe) {
|
|
|
5874
6090
|
const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
|
|
5875
6091
|
return { ...recipe, env, ...(hooks ? { hooks } : {}) };
|
|
5876
6092
|
}
|
|
6093
|
+
/** Spawn. With `o.prepared` (workspace model: a resolution prepared by
|
|
6094
|
+
* instance-resolution.mjs) this is ASYNC — capabilities are fetched and copied
|
|
6095
|
+
* into the home. Without it (classic soul-directory spawn: schedules, tests,
|
|
6096
|
+
* bare agents roots) it stays synchronous and returns the result directly. */
|
|
5877
6097
|
export function spawnInstance(root, agent, o = {}) {
|
|
6098
|
+
if (o.prepared) throw new Error("spawnInstance: a prepared (workspace) spawn is async — call spawnInstanceAsync");
|
|
6099
|
+
// Generator trick: the body is written once (below) as a generator that yields
|
|
6100
|
+
// exactly at its one asynchronous point. The classic path never reaches that
|
|
6101
|
+
// yield, so driving the generator to completion here is fully synchronous and
|
|
6102
|
+
// throws synchronously.
|
|
6103
|
+
const it = spawnBody(root, agent, o);
|
|
6104
|
+
const step = it.next();
|
|
6105
|
+
if (!step.done) throw new Error("spawnInstance: classic spawn reached an asynchronous step (materialize) without `prepared`");
|
|
6106
|
+
return step.value;
|
|
6107
|
+
}
|
|
6108
|
+
export async function spawnInstanceAsync(root, agent, o = {}) {
|
|
6109
|
+
const it = spawnBody(root, agent, o);
|
|
6110
|
+
let step = it.next();
|
|
6111
|
+
while (!step.done) {
|
|
6112
|
+
// The single yield hands back a promise-producing thunk; await it and feed the
|
|
6113
|
+
// result (or throw the failure) back into the body.
|
|
6114
|
+
try { const value = await step.value(); step = it.next(value); }
|
|
6115
|
+
catch (e) { step = it.throw(e); }
|
|
6116
|
+
}
|
|
6117
|
+
return step.value;
|
|
6118
|
+
}
|
|
6119
|
+
function* spawnBody(root, agent, o = {}) {
|
|
6120
|
+
const deliver = (r) => r;
|
|
5878
6121
|
const work = o.work || agent.work || "checkout";
|
|
5879
6122
|
if (!WORK_MODES.includes(work)) throw new Error(`unknown work mode "${work}" (${WORK_MODES.join("|")})`);
|
|
5880
6123
|
if (work === "directory" && (o.workDir !== undefined || o.branch !== undefined)) {
|
|
@@ -6250,10 +6493,10 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6250
6493
|
// roll back — no home, no worktree, no identity, no tmux window.
|
|
6251
6494
|
// Capability-defined agents carry _soulDir (read-only soul inside the package).
|
|
6252
6495
|
const soulDir = agent._soulDir || soulOf(agent._dir);
|
|
6253
|
-
const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
|
|
6496
|
+
const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind, o.prepared);
|
|
6254
6497
|
const resolvedCfg = composition.resolved;
|
|
6255
6498
|
const yolo = resolveYolo(o.yolo ?? launchSelection.configuredYolo ?? agent.yolo ?? resolvedCfg.yolo);
|
|
6256
|
-
const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
|
|
6499
|
+
const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition, prepared: o.prepared });
|
|
6257
6500
|
// Runtime extensions selected by ACTIVE capabilities for THIS instance's
|
|
6258
6501
|
// runtime. Strict launch disables ambient extension discovery, so each one has
|
|
6259
6502
|
// to be named by path — and a required runtime package that is not installed
|
|
@@ -6306,12 +6549,22 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6306
6549
|
const d = { instance, home, branch: plannedBranch, base: plannedBase,
|
|
6307
6550
|
effective: { repo: repoAbs, work, runtime, model: model || null, launchConfig: launchConfig?.name ?? null, yolo: yolo ?? null, backend, // backend regardless of --no-launch: the decision is what WOULD launch
|
|
6308
6551
|
childSpawns: ownChildPolicy.allowed, relation: relation ? { kind: relation, anchor: { instance: relativeTo ?? null, agentsRoot: anchorHome ? dirname(dirname(dirname(anchorHome))) : null } } : null } };
|
|
6552
|
+
// Workspace model: the decision binds WHAT WILL BE MATERIALIZED — the
|
|
6553
|
+
// resolution revision (member commits, package commits, payloads). A member
|
|
6554
|
+
// that moved between preview and apply changes it → E_DECISION_STALE.
|
|
6555
|
+
if (o.prepared) d.resolution = o.prepared.resolution.revision;
|
|
6309
6556
|
d.revision = createHash("sha256").update(canonicalJson(d)).digest("hex").slice(0, 24);
|
|
6310
6557
|
return d;
|
|
6311
6558
|
};
|
|
6312
6559
|
if (o.preview === true) {
|
|
6313
6560
|
const decision = buildDecision();
|
|
6314
|
-
|
|
6561
|
+
// Workspace model (M3): the preview reports what APPLY will produce — every
|
|
6562
|
+
// module as a capability (name + origin) and every module skill by its
|
|
6563
|
+
// directory basename with a `module:<cap>` source — not the classic chain's view.
|
|
6564
|
+
const preparedCapabilities = o.prepared ? o.prepared.resolution.modules.map((m) => ({ name: m.name, origin: m.from.kind === "package" ? `package:${m.from.package}@${m.from.version}` : `member:${m.from.repoKey}@${m.from.commit}` })) : null;
|
|
6565
|
+
const preparedSkills = o.prepared ? o.prepared.resolution.modules.flatMap((m) => (m.manifest?.skills || []).filter((s) => typeof s === "string" && s).map((s) => ({ name: basename(s.replace(/\/+$/, "")), source: `module:${m.name}` }))) : [];
|
|
6566
|
+
return deliver({
|
|
6567
|
+
...(o.prepared ? { modules: o.prepared.preview ?? null, team: o.prepared.soulEntry?.team ?? null, resolution: o.prepared.resolution.revision, workspace: o.prepared.discovery?.key ?? null, standalone: o.prepared.discovery?.standalone === true } : {}),
|
|
6315
6568
|
spawnPreviewApi: 2, preview: true, agent: agent.name, kind: agent.kind || "persistent", instance, home, repo: repoAbs, work,
|
|
6316
6569
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
6317
6570
|
decision, preflight,
|
|
@@ -6319,9 +6572,9 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6319
6572
|
runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
6320
6573
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
6321
6574
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
6322
|
-
policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: resolvedCfg.capabilities.map((c) => c.id),
|
|
6323
|
-
skills: expectedResources.filter((r) => r.type === "skill-tree").flatMap((r) => r.entries || []), task: task || null,
|
|
6324
|
-
};
|
|
6575
|
+
policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: preparedCapabilities ?? resolvedCfg.capabilities.map((c) => c.id),
|
|
6576
|
+
skills: [...expectedResources.filter((r) => r.type === "skill-tree" && !r.deferred).flatMap((r) => r.entries || []), ...preparedSkills], task: task || null,
|
|
6577
|
+
});
|
|
6325
6578
|
}
|
|
6326
6579
|
if (o.expectDecision !== undefined) {
|
|
6327
6580
|
// A confirmed preview binds THIS apply: same name, home, branch and base
|
|
@@ -6331,6 +6584,12 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6331
6584
|
const fresh = buildDecision();
|
|
6332
6585
|
if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
|
|
6333
6586
|
}
|
|
6587
|
+
// Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
|
|
6588
|
+
// an absent tmux/herdr binary must fail with nothing created, never after a
|
|
6589
|
+
// populated home exists. Backend STARTUP (ensureHerdr) still waits for the
|
|
6590
|
+
// bound decision and the exclusive placement below — a stale or losing apply
|
|
6591
|
+
// must not start a daemon.
|
|
6592
|
+
if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}; nothing was created`);
|
|
6334
6593
|
// Exclusive placement reservation: the parent may be created, the home
|
|
6335
6594
|
// itself never with `recursive` — EEXIST means another spawn (a concurrent
|
|
6336
6595
|
// apply of the same decision, or anything else) got here first, and this one
|
|
@@ -6341,14 +6600,12 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6341
6600
|
if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
|
|
6342
6601
|
throw e;
|
|
6343
6602
|
}
|
|
6344
|
-
// Backend startup only now — the decision is bound and the placement is ours.
|
|
6345
|
-
if (launch && !which(backend)) { try { rmdirSync(home); } catch { /* keep whatever is there */ } throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`); }
|
|
6346
|
-
herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
|
|
6347
6603
|
// TOCTOU: the placement checks above ran BEFORE composition and the runtime
|
|
6348
6604
|
// package preflight, both of which shell out — a window in which anything able
|
|
6349
6605
|
// to write in the agent directory can swap `instances/` for a link elsewhere,
|
|
6350
6606
|
// and mkdirSync follows it (reviewer-a6aa1c5). Re-assert on the directory that
|
|
6351
|
-
// now exists, before a single file is written into it
|
|
6607
|
+
// now exists, before a single file is written into it (materialize included)
|
|
6608
|
+
// or any hook runs.
|
|
6352
6609
|
// This narrows the window to the mkdir itself rather than closing it outright:
|
|
6353
6610
|
// Node has no openat/O_NOFOLLOW-relative API, so a truly hostile filesystem
|
|
6354
6611
|
// needs OS-level protection on the deployment, not a pathname check.
|
|
@@ -6359,24 +6616,91 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6359
6616
|
try { rmdirSync(createdReal); } catch { /* not empty or not removable: leave it and say so */ }
|
|
6360
6617
|
throw oatsError("E_NO_CANONICAL_ROOT", `instance home ${home} was created at ${createdReal}, not at ${expectedHome} — the path changed after it was validated (a swapped instances/ link), so nothing has been written into it and the spawn is aborted`);
|
|
6361
6618
|
}
|
|
6619
|
+
// One rollback from here to the first lifecycle hook: the home is ours
|
|
6620
|
+
// (placement won) and nothing outside it exists yet, so removing the home
|
|
6621
|
+
// WHOLE is the entire compensation. `rmSync` recursive: a prepared home is
|
|
6622
|
+
// populated by materialize (modules, skills, AGENTS.md, instance.json) — a
|
|
6623
|
+
// non-recursive rmdir would leave it behind and the retry would find its
|
|
6624
|
+
// name taken (M1). Nothing of a classic home exists at this point either.
|
|
6625
|
+
const rollbackEmptyOrPreparedHome = (e) => {
|
|
6626
|
+
try { rmSync(home, { recursive: true, force: true }); }
|
|
6627
|
+
catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; }
|
|
6628
|
+
return e;
|
|
6629
|
+
};
|
|
6630
|
+
// Workspace model: copy every resolved capability WHOLE into the new home
|
|
6631
|
+
// (.oats/modules/<cap>/ + .agents/skills/<cap>/) and record modules/providers
|
|
6632
|
+
// in instance.json. Then REBUILD the capability rows against the copies that
|
|
6633
|
+
// landed (H1): until now `resolvedCfg.capabilities` were PLANNED rows (no
|
|
6634
|
+
// skills, no inject, no dir) — hooks, environment, requirements, retirement
|
|
6635
|
+
// and instance.json all read the rebuilt rows from here on.
|
|
6636
|
+
let materializeOutcome = null;
|
|
6637
|
+
if (o.prepared) {
|
|
6638
|
+
// The kernel's composed text (soul AGENTS.md + kernel/work-mode injects) is
|
|
6639
|
+
// the body materialize composes the capability injects onto.
|
|
6640
|
+
try {
|
|
6641
|
+
materializeOutcome = yield () => (o.materialize ?? materializePreparedDefault)({ ...o.prepared, soulAgentsMd: composition.text, soulDir }, home);
|
|
6642
|
+
const rows = (typeof o.prepared.toCapabilityRows === "function" ? o.prepared.toCapabilityRows : toCapabilityRows)(o.prepared.resolution, home);
|
|
6643
|
+
if (!Array.isArray(rows)) throw new Error("toCapabilityRows returned no rows");
|
|
6644
|
+
for (const row of rows) row.hooks = materializedHookCommands(row, home);
|
|
6645
|
+
resolvedCfg.capabilities = rows;
|
|
6646
|
+
// S1: the capability blocks materialize appended are part of the composed
|
|
6647
|
+
// instructions. Read the markers back from the AGENTS.md that was WRITTEN
|
|
6648
|
+
// (the authority on what the instance sees) and merge them into the
|
|
6649
|
+
// composition so meta.instructions / composition.expected and the
|
|
6650
|
+
// completeness check below describe the whole file, not only the kernel's
|
|
6651
|
+
// half.
|
|
6652
|
+
const written = readFileSync(join(home, "AGENTS.md"), "utf8");
|
|
6653
|
+
const known = new Set(composition.blocks.map((b) => `${b.source}\u0000${b.file}`));
|
|
6654
|
+
const outcomeBlocks = Array.isArray(materializeOutcome?.blocks) ? materializeOutcome.blocks : [];
|
|
6655
|
+
for (const m of written.matchAll(/^<!-- oats:(capability:[^\s]+) src=(.+?) -->$/gm)) {
|
|
6656
|
+
const source = m[1], file = m[2];
|
|
6657
|
+
if (known.has(`${source}\u0000${file}`)) continue;
|
|
6658
|
+
const fromOutcome = outcomeBlocks.find((b) => b.source === source && b.file === file);
|
|
6659
|
+
const content = fromOutcome?.content ?? (existsSync(file) ? readFileSync(file, "utf8").trim() : "");
|
|
6660
|
+
composition.blocks.push({ source, file, content, materialized: true });
|
|
6661
|
+
// S1: the appended block is part of what the composition PROMISES the instance.
|
|
6662
|
+
expectedResources.push({ type: "instruction-block", source, declared: file, path: file, materialized: true });
|
|
6663
|
+
known.add(`${source}\u0000${file}`);
|
|
6664
|
+
}
|
|
6665
|
+
// The expected resources deferred to materialize (module skills/injects)
|
|
6666
|
+
// are now resolvable: fill their paths so the record and the completeness
|
|
6667
|
+
// check compare promised against landed.
|
|
6668
|
+
for (const r of expectedResources) {
|
|
6669
|
+
if (r.deferred !== "materialize") continue;
|
|
6670
|
+
const row = rows.find((c) => c.id === r.module);
|
|
6671
|
+
if (r.type === "injection") { r.path = row?.inject; continue; }
|
|
6672
|
+
if (r.type === "skill-tree") {
|
|
6673
|
+
const skillsRoot = join(home, ".agents", "skills", r.module);
|
|
6674
|
+
r.path = existsSync(skillsRoot) ? skillsRoot : undefined;
|
|
6675
|
+
r.entries = (row?.skills || []).map((p) => basename(p));
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
} catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
6679
|
+
}
|
|
6680
|
+
// Backend startup only now — the decision is bound and the placement is ours.
|
|
6681
|
+
try { herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined; }
|
|
6682
|
+
catch (e) { throw rollbackEmptyOrPreparedHome(e); }
|
|
6362
6683
|
|
|
6363
6684
|
initializeNativeHistory(home);
|
|
6364
6685
|
|
|
6365
6686
|
// Body: the soul is linked for reference, while instructions are a generated instance-local view.
|
|
6366
6687
|
symlinkSync(soulDir, join(home, "soul"));
|
|
6367
|
-
writeFileSync(join(home, "AGENTS.md"), composition.text);
|
|
6368
|
-
symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
|
|
6369
|
-
|
|
6370
|
-
//
|
|
6371
|
-
|
|
6688
|
+
if (!o.prepared) { writeFileSync(join(home, "AGENTS.md"), composition.text); symlinkSync("AGENTS.md", join(home, "CLAUDE.md")); }
|
|
6689
|
+
else if (!existsSync(join(home, "CLAUDE.md"))) symlinkSync("AGENTS.md", join(home, "CLAUDE.md"));
|
|
6690
|
+
|
|
6691
|
+
// Skills: capability skills were copied WHOLE into <home>/.agents/skills/<capability>/
|
|
6692
|
+
// by materializePrepared (workspace model, decision 7/13); here only the soul's
|
|
6693
|
+
// own skills join them. The harness is launched with its normal discovery — the
|
|
6694
|
+
// machine's and the repo's skills are the harness's business, not excluded.
|
|
6695
|
+
const sources = o.prepared ? [] : legacyOperationalSkills(soulDir);
|
|
6372
6696
|
const soulSkills = join(soulDir, "skills");
|
|
6373
6697
|
if (existsSync(soulSkills)) sources.push({ id: "soul", path: soulSkills });
|
|
6374
|
-
for (const cap of resolvedCfg.capabilities) for (const path of cap.skills || []) sources.push({ id: cap.id, path });
|
|
6698
|
+
if (!o.prepared) for (const cap of resolvedCfg.capabilities) for (const path of cap.skills || []) sources.push({ id: cap.id, path });
|
|
6375
6699
|
// A capability-defined agent always carries its OWN capability's skills and
|
|
6376
6700
|
// injection, regardless of config targeting (the reviewer needs its review
|
|
6377
6701
|
// skills even though oats.review targets the developers type).
|
|
6378
6702
|
if (agent.kind === "capability" && agent.capability && !resolvedCfg.capabilities.some((c) => c.id === agent.capability)) {
|
|
6379
|
-
for (const path of capabilitySkillDirs(agent.capability, repoAbs)) sources.push({ id: agent.capability, path });
|
|
6703
|
+
for (const path of capabilitySkillDirs(agent.capability, agent._manifestSource ?? repoAbs)) sources.push({ id: agent.capability, path });
|
|
6380
6704
|
}
|
|
6381
6705
|
// Skill names come from parsed config and from skill directories: on a plain
|
|
6382
6706
|
// object, `"constructor" in overrides` is already true and `overrides[name]`
|
|
@@ -6403,7 +6727,7 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6403
6727
|
// Copy each selected tree so the exact instance-local set is real and immutable.
|
|
6404
6728
|
copyTreeSafe(realpathSync(selected.src), join(home, ".agents", "skills", name));
|
|
6405
6729
|
}
|
|
6406
|
-
symlinkSync(join("..", ".agents", "skills"), join(home, ".claude", "skills"));
|
|
6730
|
+
if (!existsSync(join(home, ".claude", "skills"))) symlinkSync(join("..", ".agents", "skills"), join(home, ".claude", "skills"));
|
|
6407
6731
|
|
|
6408
6732
|
// EXPECTED == MATERIALIZED. Preflight proved every declared resource resolves;
|
|
6409
6733
|
// this proves the copies actually landed, so "the composition is complete" is
|
|
@@ -6420,12 +6744,30 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6420
6744
|
// entered the set (reviewer-400c1e6). Matching is by NAME because an explicit
|
|
6421
6745
|
// skill-override may legitimately satisfy a promised name from another source.
|
|
6422
6746
|
for (const r of expectedResources) {
|
|
6747
|
+
if (r.deferred === "materialize") continue; // module skills: verified against the landed copies just below
|
|
6423
6748
|
for (const name of r.entries || []) {
|
|
6424
6749
|
if (!chosen.has(name)) incomplete.push(`skill "${name}", promised by ${r.source} (${r.declared}), is missing from the composed set`);
|
|
6425
6750
|
}
|
|
6426
6751
|
}
|
|
6752
|
+
// Prepared spawn: every module's declared skill must have been copied under
|
|
6753
|
+
// .agents/skills/<module>/<skill>/ by materialize — verify the copies landed
|
|
6754
|
+
// as readable skills (the module directory alone proves nothing).
|
|
6755
|
+
if (o.prepared) {
|
|
6756
|
+
for (const m of o.prepared.resolution.modules) for (const s of m.manifest.skills || []) {
|
|
6757
|
+
const sk = join(home, ".agents", "skills", m.name);
|
|
6758
|
+
if (!existsSync(sk)) { incomplete.push(`module "${m.name}" declares skills but .agents/skills/${m.name} is absent`); break; }
|
|
6759
|
+
}
|
|
6760
|
+
for (const r of expectedResources) {
|
|
6761
|
+
if (r.deferred !== "materialize" || r.type !== "skill-tree") continue;
|
|
6762
|
+
if (!r.path) { incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but .agents/skills/${r.module} is absent`); continue; }
|
|
6763
|
+
if (!r.entries.length) incomplete.push(`module "${r.module}" declares skill tree ${r.declared} but no skill was copied under .agents/skills/${r.module}`);
|
|
6764
|
+
for (const name of r.entries) if (!hasSkillDoc(join(r.path, name))) incomplete.push(`skill "${name}" (module ${r.module}) did not materialize as a readable SKILL.md`);
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6427
6767
|
for (const r of expectedResources) {
|
|
6428
|
-
if (r.type
|
|
6768
|
+
if (r.type !== "injection") continue;
|
|
6769
|
+
if (r.deferred === "materialize" && !r.path) { incomplete.push(`injection from ${r.source} (${r.declared}) was not materialized into the module copy`); continue; }
|
|
6770
|
+
if (!composition.blocks.some((b) => b.file === r.path)) incomplete.push(`injection from ${r.source} (${r.declared}) resolved but is not present in the composed AGENTS.md`);
|
|
6429
6771
|
}
|
|
6430
6772
|
const aliasTarget = realPathOrNearest(join(home, ".claude", "skills"));
|
|
6431
6773
|
if (aliasTarget !== realPathOrNearest(join(home, ".agents", "skills"))) {
|
|
@@ -6536,6 +6878,10 @@ export function spawnInstance(root, agent, o = {}) {
|
|
|
6536
6878
|
extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
|
|
6537
6879
|
});
|
|
6538
6880
|
warnings.push(...hookRes.warnings);
|
|
6881
|
+
// Which capability hooks RAN (in order) and how each ended — recorded on the
|
|
6882
|
+
// `spawned` event below: the instance's event log is the auditable fact that
|
|
6883
|
+
// a capability configured itself, independent of whatever the hook printed.
|
|
6884
|
+
const hookReceipt = (res) => { const failedBy = new Map((res.failures || []).map((f) => [f.capability, f])); return (res.order || []).map((id) => ({ capability: id, ok: !failedBy.has(id), ...(failedBy.has(id) ? { required: failedBy.get(id).required === true, contract: failedBy.get(id).contract ?? null } : {}), meta: Object.hasOwn(res.meta || {}, id) })); };
|
|
6539
6885
|
const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
|
|
6540
6886
|
let windowMayExist = false;
|
|
6541
6887
|
let spawnTmux;
|
|
@@ -6785,7 +7131,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6785
7131
|
// keeping both makes an instance's surface reviewable after the fact without
|
|
6786
7132
|
// re-resolving config that may since have changed.
|
|
6787
7133
|
composition: {
|
|
6788
|
-
expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
|
|
7134
|
+
expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level, ...(r.deferred ? { deferred: r.deferred } : {}) })),
|
|
6789
7135
|
materialized: {
|
|
6790
7136
|
skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
|
|
6791
7137
|
instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
|
|
@@ -6826,6 +7172,12 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6826
7172
|
...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
|
|
6827
7173
|
launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
|
|
6828
7174
|
};
|
|
7175
|
+
// Workspace model: materialize recorded modules/providers (and the module
|
|
7176
|
+
// digests) in instance.json before this metadata is assembled — carry them.
|
|
7177
|
+
if (o.prepared) {
|
|
7178
|
+
try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
|
|
7179
|
+
meta.workspace = { key: o.prepared.discovery?.key ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null } };
|
|
7180
|
+
}
|
|
6829
7181
|
const spawnWarnings = warnings;
|
|
6830
7182
|
|
|
6831
7183
|
spawnTmux = meta.tmux;
|
|
@@ -6895,14 +7247,14 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
6895
7247
|
}
|
|
6896
7248
|
}
|
|
6897
7249
|
|
|
6898
|
-
appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch } });
|
|
7250
|
+
appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch, hooks: hookReceipt(hookRes) } });
|
|
6899
7251
|
if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
|
|
6900
7252
|
if (o.idempotencyKey !== undefined) {
|
|
6901
7253
|
// Only now is the spawn a finished receipt a same-key retry may replay.
|
|
6902
7254
|
meta.spawnCompleted = true;
|
|
6903
7255
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
6904
7256
|
}
|
|
6905
|
-
return { ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
|
|
7257
|
+
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
6906
7258
|
} catch (error) {
|
|
6907
7259
|
const note = compensateSpawn();
|
|
6908
7260
|
error.message += note;
|
|
@@ -7504,6 +7856,11 @@ export function recomposeInstanceInstructions(home, { dryRun = false } = {}) {
|
|
|
7504
7856
|
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired; nothing recomposed`);
|
|
7505
7857
|
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
7506
7858
|
if (meta.executionBinding || meta.captured) throw oatsError("E_UNSUPPORTED_MODE", "captured incarnations are recomposed by preparing a new resolution, not in place");
|
|
7859
|
+
// Workspace model (M2): a home whose capabilities were MATERIALIZED as modules
|
|
7860
|
+
// composed its AGENTS.md from the module copies (capability injects under
|
|
7861
|
+
// .oats/modules/). The classic composer knows nothing of them and would
|
|
7862
|
+
// silently strip every capability block. Refuse rather than degrade.
|
|
7863
|
+
if (meta.modules && typeof meta.modules === "object" && !Array.isArray(meta.modules)) throw oatsError("E_UNSUPPORTED_MODE", "recompose from materialized modules is not supported yet; re-spawn");
|
|
7507
7864
|
const soulLink = join(realHome, "soul");
|
|
7508
7865
|
let soulDir; try { soulDir = realpathSync(soulLink); } catch { throw oatsError("E_SOUL_UNKNOWN", `${realHome} has no readable soul link`); }
|
|
7509
7866
|
const agentDir = dirname(dirname(realHome));
|
|
@@ -7771,7 +8128,10 @@ export function recipeFromLegacyCommand(meta, home) {
|
|
|
7771
8128
|
const extras = [];
|
|
7772
8129
|
const expect = (...seq) => { for (const w of seq) { if (value(i) !== w) throw oatsError("E_LAUNCH_LEGACY", `the recorded ${runtime} command is not the kernel's generated shape (expected ${JSON.stringify(w)} at argument ${i + 1}); it cannot be converted`); i++; } };
|
|
7773
8130
|
if (runtime === "pi") {
|
|
7774
|
-
|
|
8131
|
+
// Two generated shapes exist on disk: homes launched before the workspace
|
|
8132
|
+
// model carry the strict-curriculum prefix; new homes start pi normally.
|
|
8133
|
+
if (value(i) === "--no-skills") expect("--no-skills", "--skill", join(home, ".agents", "skills"), "--no-context-files", "--no-prompt-templates");
|
|
8134
|
+
expect("--append-system-prompt", join(home, "AGENTS.md"), "--approve", "--name", meta.instance);
|
|
7775
8135
|
if (value(i) === "--model") { model = value(i + 1) ?? null; i += 2; }
|
|
7776
8136
|
expect("@TASK.md"); sawPrompt = true;
|
|
7777
8137
|
} else {
|
|
@@ -9096,7 +9456,11 @@ export function retireInstance(root, name, o = {}) {
|
|
|
9096
9456
|
else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
|
|
9097
9457
|
if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
|
|
9098
9458
|
}
|
|
9099
|
-
|
|
9459
|
+
// `hooks`: which retire hooks ran (in order) and how each ended — the same
|
|
9460
|
+
// receipt `spawned` carries, so the workspace log shows both halves of a
|
|
9461
|
+
// capability's lifecycle after the home is gone.
|
|
9462
|
+
const retireHookReceipt = (() => { const res = hookResults || {}; const failedBy = new Map((res.failures || []).map((f) => [f.capability, f])); return (res.order || []).map((id) => ({ capability: id, ok: !failedBy.has(id), meta: Object.hasOwn(res.meta || {}, id) })); })();
|
|
9463
|
+
appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null, hooks: retireHookReceipt } }, { workspaceOnly: true });
|
|
9100
9464
|
// Retrying a quarantine only clears it if compensation ACTUALLY completed.
|
|
9101
9465
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
9102
9466
|
// retry becomes the deletion the quarantine was preventing.
|