@awebai/oats 0.24.12 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/bin/oats.mjs +936 -2822
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  5. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  6. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  7. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  8. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  9. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  10. package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
  11. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  12. package/docs/design/README.md +20 -8
  13. package/docs/design/operations-contract.md +1 -0
  14. package/docs/design/package-engine-contract.md +1 -1
  15. package/docs/design/package-runtime-api.md +1 -1
  16. package/docs/desktop-cli-api.md +386 -6
  17. package/docs/desktop-succession.md +3 -4
  18. package/docs/first-team.md +107 -224
  19. package/docs/implementation.md +6 -4
  20. package/docs/integrations.md +45 -44
  21. package/docs/knowledge-capability-authoring.md +10 -4
  22. package/docs/knowledge-migration.md +5 -4
  23. package/docs/knowledge-reference/package-craft.md +11 -3
  24. package/docs/knowledge.md +24 -8
  25. package/docs/layers.md +3 -3
  26. package/docs/oats-local.schema.json +50 -0
  27. package/docs/oats-membership.schema.json +23 -0
  28. package/docs/oats-workspace.schema.json +133 -48
  29. package/docs/official-marketplace.md +9 -6
  30. package/docs/packages.md +229 -440
  31. package/docs/rebuild-to-v2.md +233 -0
  32. package/docs/release-notes/v0.24.13.md +51 -0
  33. package/docs/release-notes/v0.25.0.md +99 -0
  34. package/docs/soul.schema.json +41 -68
  35. package/docs/souls-and-instances.md +175 -108
  36. package/docs/workspace-adoption.md +70 -345
  37. package/docs/workspaces.md +429 -119
  38. package/lib/core.mjs +419 -55
  39. package/lib/instance-resolution.mjs +312 -0
  40. package/lib/materialize.mjs +580 -0
  41. package/lib/packages.mjs +501 -1273
  42. package/lib/remote.mjs +639 -0
  43. package/lib/resolve.mjs +576 -0
  44. package/lib/schedule.mjs +194 -34
  45. package/lib/workspace.mjs +635 -0
  46. package/package.json +1 -1
  47. package/lib/portable-migration-artifacts.mjs +0 -135
  48. package/lib/portable-migration-evidence.mjs +0 -305
  49. package/lib/portable-migration-store.mjs +0 -199
  50. package/lib/portable-migration.mjs +0 -104
  51. package/lib/portable-onboarding-acceptance.mjs +0 -66
  52. package/lib/setup-expert-source.mjs +0 -100
package/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
- throw new Error(
589
- `no agents/, local-agents/, or deployment oats-config.yaml found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
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
- if (!/[.@/]/.test(id)) throw new Error(`capability ID must be namespaced: "${id}" (${mf})`);
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
- return parseLockBytesStrict(readFileSync(file), { file });
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
- for (const { path } of legacyOperationalSkills(soulDir)) {
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
- for (const s of capabilityDeclaredSkills(agent.capability, contextDir)) {
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
- const remedy = inactiveOps.map((id) => `oats use ${id} --soul ${soulName}${contextDir ? ` --dir ${contextDir}` : ""}`).join(" && ");
4291
- 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 Acquire if needed (\`oats install oats.framework\`), then activate: ${remedy}\n Or remove the declaration from ${join(soulDir, "soul.yaml")} to run without it.`),
4292
- { soul: soulName, capabilities: inactiveOps, context: contextDir, remedy });
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 runs the STRICT CURRICULUM: the OATS-composed skill set and AGENTS.md
5665
- * only (--no-skills + --skill, --no-context-files, --no-prompt-templates,
5666
- * --append-system-prompt); extensions stay ambient by founder ruling (no
5667
- * --no-extensions, no -e), and the task positional goes ahead of contributed
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
- cmdline = `${shq(executable)} --no-skills --skill ${shq(join(home, ".agents", "skills"))} --no-context-files --no-prompt-templates --append-system-prompt ${shq(join(home, "AGENTS.md"))} --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${tail}`;
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
- return {
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 or any hook runs.
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
- // Runtime-neutral exact skill materialization. No harness receives ambient workspace/package skills.
6371
- const sources = legacyOperationalSkills(soulDir);
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 === "injection" && !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`);
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
- expect("--no-skills", "--skill", join(home, ".agents", "skills"), "--no-context-files", "--no-prompt-templates", "--append-system-prompt", join(home, "AGENTS.md"), "--approve", "--name", meta.instance);
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
- appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null } }, { workspaceOnly: true });
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.