@awebai/oats 0.27.2 → 0.29.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 (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
package/lib/core.mjs CHANGED
@@ -517,7 +517,7 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
517
517
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
518
518
  /** Environment the kernel sets for every launch (identity, home, roots) and
519
519
  * its reference aliases: a configuration may not name them. */
520
- export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
520
+ export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
521
521
  export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
522
522
  const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
523
523
  export function validateLaunchConfig(name, entry, where) {
@@ -680,7 +680,9 @@ function loadManifestAt(idir, origin, { strict = false } = {}) {
680
680
  if (targetFields.length) throw new Error(`capability ${id} manifest cannot declare config-owned targets: ${targetFields.join(", ")}`);
681
681
  if (m.layer && !LAYERS.includes(m.layer)) throw new Error(`capability ${id} declares unknown layer "${m.layer}"`);
682
682
  if (m.command && !/^[a-z0-9][a-z0-9-]*$/.test(m.command)) throw new Error(`capability ${id} has invalid command namespace "${m.command}"`);
683
- if (m.agents !== undefined && (!Array.isArray(m.agents) || m.agents.some((a) => typeof a !== "string"))) throw new Error(`capability ${id} "agents" must be an array of package-relative soul directories`);
683
+ // `agents:` (capability-defined agents) was removed in 0.29.0: resolution refuses a module that
684
+ // declares it (E_CAPABILITY_AGENTS_REMOVED). A copy already recorded in a home still loads here,
685
+ // so that home launches and retires; nothing reads the key.
684
686
  validateManifestOperations(m, id);
685
687
  validateBindingInterface(m);
686
688
  return { ...m, _dir: idir, _origin: origin };
@@ -917,33 +919,6 @@ function manifestPath(manifest, rel) {
917
919
  }
918
920
  /** Resolve an executable declared by a manifest through the same artifact boundary as hooks. */
919
921
  export function capabilityExecutablePath(manifest, rel) { return manifestPath(manifest, rel); }
920
- function assertCapabilityTreeContained(manifest, tree, resource = "skill") {
921
- const artifact = realpathSync(manifest._dir);
922
- const visited = new Set();
923
- const assertInside = (target, path) => {
924
- const fromArtifact = relative(artifact, target);
925
- if (fromArtifact === ".." || fromArtifact.startsWith(`..${sep}`) || isAbsolute(fromArtifact)) {
926
- throw new Error(`capability ${manifest.capability} ${resource} path escapes its integrity boundary: ${relative(manifest._dir, path)}`);
927
- }
928
- };
929
- const walk = (dir) => {
930
- const realDir = realpathSync(dir);
931
- assertInside(realDir, dir);
932
- if (visited.has(realDir)) return;
933
- visited.add(realDir);
934
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
935
- const path = join(dir, entry.name);
936
- const target = realpathSync(path); // also rejects broken symlinks
937
- assertInside(target, path);
938
- if (entry.isSymbolicLink()) {
939
- // Recurse through contained directory links: descendants may carry a
940
- // second symlink that escapes the package boundary.
941
- if (lstatSync(target).isDirectory()) walk(target);
942
- } else if (entry.isDirectory()) walk(path);
943
- }
944
- };
945
- walk(tree);
946
- }
947
922
  /** Packaged default injection for a capability or work mode (undefined if none shipped). */
948
923
  export function packagedInject(name, startDir) {
949
924
  const m = capabilityManifest(name, startDir);
@@ -1014,7 +989,6 @@ export function composeInstanceAgentsMd(soulDir, contextDir, soulName, workMode,
1014
989
  const workModeInject = packagedInject(`work-${workMode || "checkout"}`);
1015
990
  if (workModeInject && existsSync(workModeInject)) wanted.push([`work-mode:${workMode || "checkout"}`, workModeInject]);
1016
991
  for (const cap of resolved.capabilities) {
1017
- if (kind === "capability" && cap.layer === "knowledge") continue; // ephemeral: no memory protocol
1018
992
  if (cap.inject && existsSync(cap.inject)) wanted.push([`capability:${cap.id}`, cap.inject]);
1019
993
  }
1020
994
  const blocks = wanted.map(([source, file]) => ({ source, file, content: readFileSync(file, "utf8").trim() }));
@@ -1136,20 +1110,12 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
1136
1110
  if (typeof declared !== "string" || !declared) continue;
1137
1111
  expected.push({ type: "skill-tree", source: cap.id, declared, path: undefined, entries: [], deferred: "materialize", origin: cap.origin, module: cap.id });
1138
1112
  }
1139
- const intentionallyDroppedPlanned = agent?.kind === "capability" && cap.layer === "knowledge";
1140
- if (cap.injectDeclared && !intentionallyDroppedPlanned) expected.push({ type: "injection", source: cap.id, declared: cap.injectDeclared, path: undefined, deferred: "materialize", origin: cap.origin, module: cap.id });
1113
+ if (cap.injectDeclared) expected.push({ type: "injection", source: cap.id, declared: cap.injectDeclared, path: undefined, deferred: "materialize", origin: cap.origin, module: cap.id });
1141
1114
  continue;
1142
1115
  }
1143
1116
  for (const s of cap.skillsDeclared || []) {
1144
1117
  add({ type: "skill-tree", source: cap.id, declared: s.declared, path: s.path, origin: cap.origin, level: cap.level });
1145
1118
  }
1146
- // Capability agents are ephemeral and deliberately get no memory protocol,
1147
- // so composeInstanceAgentsMd drops knowledge-layer injections for them.
1148
- // The expected set MUST apply the same rule or it reports an intentional
1149
- // omission as an incomplete composition. (Coupled to the matching `continue`
1150
- // in composeInstanceAgentsMd — change both together.)
1151
- const intentionallyDropped = agent?.kind === "capability" && cap.layer === "knowledge";
1152
- if (intentionallyDropped) continue;
1153
1119
  // A capability that declares `inject:` must produce it.
1154
1120
  if (cap.injectDeclared && cap.inject === undefined) {
1155
1121
  add({ type: "injection", source: cap.id, declared: cap.injectDeclared, path: undefined, origin: cap.origin, level: cap.level });
@@ -1501,6 +1467,11 @@ export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
1501
1467
  return "";
1502
1468
  }
1503
1469
  export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
1470
+ /** A triggered instance's event, inside its home's .oats/ (lib/triggers.mjs). */
1471
+ export const TRIGGER_EVENT_FILE = "trigger-event.json";
1472
+ /** A prepared soul entry's id: `<repoKey>#<name>` for a member or external soul, `package:<id>#<name>`
1473
+ * for a package soul (stable across the package's versions and independent of its repo). */
1474
+ const preparedSoulIdOf = (entry) => workspaceSoulId(typeof entry.package === "string" ? `package:${entry.package}` : entry.repoKey, entry.name);
1504
1475
  /** The soul directory an instance incarnates, as spawn recorded it (instance.json
1505
1476
  * `soulDir`): a workspace soul's per-commit copy (agents/<soul>/souls/<commit12>) or
1506
1477
  * the read-only soul inside a capability package. It is what every classic
@@ -1584,6 +1555,10 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1584
1555
  // a caller's extraEnv: those may point at a different executable.
1585
1556
  OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
1586
1557
  OATS_SETTINGS: JSON.stringify(cap.settings || {}),
1558
+ // Where each leaf of OATS_SETTINGS came from (JSON pointer → { kind, at }; kind is
1559
+ // manifest-default | workspace | soul | host | spawn | anchor), so a provider can tell a
1560
+ // soul-set value from a host-set one. {} when the home recorded none (spawned before 0.29).
1561
+ OATS_SETTINGS_ORIGINS: JSON.stringify(cap.settingsOrigins || {}),
1587
1562
  OATS_META: JSON.stringify(priorMeta[cap.id] || {}),
1588
1563
  },
1589
1564
  }).trim();
@@ -1600,7 +1575,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1600
1575
  // A launch hook's run is recorded even when its answer is empty: an
1601
1576
  // empty answer replaces what the provider contributed before.
1602
1577
  if (event === "launch" || (o.launch && typeof o.launch === "object" && Object.keys(o.launch).length) || (o.env && typeof o.env === "object" && Object.keys(o.env).length)) {
1603
- results.contributions.push({ capability: cap.id, layer: cap.layer || null, level: cap.level || null, settings: { ...(cap.settings || {}) }, trust: { trusted: !!cap.trust?.trusted, integrity: cap.trust?.integrity || null }, launch: o.launch && typeof o.launch === "object" ? { ...o.launch } : {}, env: o.env && typeof o.env === "object" ? Object.keys(o.env).sort() : [] });
1578
+ results.contributions.push({ capability: cap.id, layer: cap.layer || null, level: cap.level || null, settings: { ...(cap.settings || {}) }, settingsOrigins: { ...(cap.settingsOrigins || {}) }, trust: { trusted: !!cap.trust?.trusted, integrity: cap.trust?.integrity || null }, launch: o.launch && typeof o.launch === "object" ? { ...o.launch } : {}, env: o.env && typeof o.env === "object" ? Object.keys(o.env).sort() : [] });
1604
1579
  }
1605
1580
  if (o.env !== undefined) {
1606
1581
  if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn and launch, not ${event}`);
@@ -1672,7 +1647,10 @@ function readSoul(agentDir, soulDir = soulOf(agentDir)) {
1672
1647
  }
1673
1648
  const soul = soulHarnessField(stripInternalAnnotations(parsed), p);
1674
1649
  soul._dir = agentDir;
1675
- soul.name = soul.name || basename(agentDir);
1650
+ // A package soul homes at <package>--<soul> (lib/workspace.mjs packageSoulAgentName): that
1651
+ // directory, not the soul.yaml name, is its agent name, so its instances never share a
1652
+ // member soul's name or roster row.
1653
+ soul.name = basename(agentDir).includes("--") ? basename(agentDir) : (soul.name || basename(agentDir));
1676
1654
  return soul;
1677
1655
  }
1678
1656
  export function findAgent(root, name) {
@@ -1686,91 +1664,6 @@ export function findAgentAt(root, name, soulDir) {
1686
1664
  return agent;
1687
1665
  }
1688
1666
 
1689
- /** Canonical capability-defined agents: a manifest's `agents: ["agents/reviewer"]`
1690
- * entries are package-relative soul directories (soul.yaml + AGENTS.md directly
1691
- * inside). They resolve when the capability is ACTIVE in the
1692
- * context; the soul stays read-only in the package (fresh identity every spawn —
1693
- * no long-term memory), while instances home under <root>/<name>/instances/ (the
1694
- * agent dir holds only instances/). */
1695
- function capabilityAgentMetadata(manifest, rel) {
1696
- const soulDir = manifestPath(manifest, rel);
1697
- const soulFile = manifestPath(manifest, join(rel, "soul.yaml"));
1698
- if (!soulDir || !soulFile) return undefined;
1699
- // Read only contained identity metadata to decide whether this provider owns
1700
- // the requested name. Full tree containment + trust happen after a match.
1701
- const soul = soulHarnessField(stripInternalAnnotations(withConfigFile(soulFile, () => parseYamlFlat(readFileSync(soulFile, "utf8")))), soulFile);
1702
- return { soulDir, soul, name: soul.name || basename(soulDir) };
1703
- }
1704
- /**
1705
- * Workspace model: a capability-defined agent (a package's `agents:` soul, e.g.
1706
- * OKF's memory-harvest worker) resolves from a MATERIALIZED module — the copy the
1707
- * requesting instance already carries under <home>/.oats/modules/<cap>/. Looks in
1708
- * `anchorHome` first (the --parent / --relative-to instance), then every instance
1709
- * home under `root`; the first module declaring an agent of that name wins.
1710
- * → the same shape findCapabilityAgent returns, plus `_manifestSource` (the home
1711
- * whose modules supplied it) so skill/inject lookups read that copy.
1712
- */
1713
- export function findModuleCapabilityAgent(root, name, { anchorHome = null } = {}) {
1714
- if (typeof name !== "string" || !name) return undefined;
1715
- const homes = [];
1716
- if (anchorHome) homes.push(anchorHome);
1717
- if (root && existsSync(root)) {
1718
- for (const a of readdirSync(root, { withFileTypes: true })) {
1719
- if (!a.isDirectory() || a.name.startsWith(".")) continue;
1720
- const inst = join(root, a.name, "instances");
1721
- if (!existsSync(inst)) continue;
1722
- for (const i of readdirSync(inst, { withFileTypes: true })) if (i.isDirectory() && !i.name.startsWith(".")) homes.push(join(inst, i.name));
1723
- }
1724
- }
1725
- const seen = new Set();
1726
- const failures = [];
1727
- for (const home of homes) {
1728
- const real = (() => { try { return realpathSync(home); } catch { return null; } })();
1729
- if (!real || seen.has(real)) continue;
1730
- seen.add(real);
1731
- const modules = instanceModulesRoot(real);
1732
- if (!modules) continue;
1733
- for (const [id, manifest] of Object.entries(capabilityManifests(real))) {
1734
- for (const rel of manifest?.agents || []) {
1735
- let meta;
1736
- try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
1737
- if (!meta || meta.name !== name) continue;
1738
- try {
1739
- assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
1740
- return {
1741
- ...meta.soul, name,
1742
- kind: "capability", capability: id,
1743
- _dir: join(root, name),
1744
- _soulDir: meta.soulDir,
1745
- _manifestSource: real,
1746
- _module: manifest._module,
1747
- };
1748
- } catch (e) { failures.push(e); }
1749
- }
1750
- }
1751
- }
1752
- if (failures.length) throw failures[0];
1753
- return undefined;
1754
- }
1755
- /**
1756
- * A capability-defined agent from ONE capability directory (a package tree the
1757
- * workspace resolver fetched into the deployment's module store). The manifest is
1758
- * read from `dir`, trusted by construction (the workspace declares the package; the lock pins it), and
1759
- * the agent record points its skill/inject lookups at that store entry.
1760
- */
1761
- export function capabilityAgentFromDir(dir, name, root, { module = null } = {}) {
1762
- const manifest = loadManifestAt(dir, `module:${dir}`);
1763
- if (!manifest) return undefined;
1764
- manifest._module = module;
1765
- for (const rel of manifest.agents || []) {
1766
- let meta;
1767
- try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
1768
- if (!meta || meta.name !== name) continue;
1769
- assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
1770
- return { ...meta.soul, name, kind: "capability", capability: manifest.capability, _dir: join(root, name), _soulDir: meta.soulDir, _manifestSource: dir, _manifest: manifest, _module: module };
1771
- }
1772
- return undefined;
1773
- }
1774
1667
  export function listAgents(root) {
1775
1668
  const agents = [];
1776
1669
  const scan = (base, kind) => {
@@ -1898,11 +1791,20 @@ const nameTooLong = (name, remedy) => oatsError("E_INSTANCE_NAME_INVALID", `inst
1898
1791
  * final name, suffix included, is capped at MAX_INSTANCE_NAME. */
1899
1792
  function nextInstanceName(root, agent, purpose, prepared) {
1900
1793
  const n = deriveInstanceName(root, agent, purpose, prepared);
1901
- if (n.length > MAX_INSTANCE_NAME) throw nameTooLong(n, purpose ? `shorten the purpose (${JSON.stringify(slug(purpose))})` : `the soul name "${agent.name}" is too long to derive an instance name from; pass a shorter --name`);
1902
- return n;
1903
- }
1794
+ if (n.length <= MAX_INSTANCE_NAME) return n;
1795
+ if (!purpose) throw nameTooLong(n, `the soul name "${agent.name}" is too long to derive an instance name from; pass a shorter --name`);
1796
+ // Say the budget: the name is <agent name>-<purpose>[-<n>], and a package soul's agent name
1797
+ // carries its package id (oats-okf-knowledge-maintainer-…), so its purposes are short.
1798
+ const p = slug(purpose), prefix = `${slug(agent.name)}-`, suffix = n.length - prefix.length - p.length;
1799
+ const budget = Math.max(0, MAX_INSTANCE_NAME - prefix.length - suffix);
1800
+ throw Object.assign(nameTooLong(n, `the purpose ${JSON.stringify(p)} is ${p.length} characters; ${agent.name}'s instances are named ${prefix}<purpose>${suffix > 0 ? "-<n>" : ""}, which leaves at most ${budget} for the purpose — shorten it, or pass --name`), { details: { prefix, purpose: p, maxPurpose: budget } });
1801
+ }
1802
+ // The stem is the agent name AS A SLUG: a package soul's agent name holds `--`
1803
+ // (acme-pkg--keeper) and its instances are acme-pkg-keeper-<purpose>, so the name
1804
+ // that is length-checked and de-duplicated here is the one the home gets.
1904
1805
  function deriveInstanceName(root, agent, purpose, prepared) {
1905
- const base = purpose ? `${agent.name}-${slug(purpose)}` : undefined;
1806
+ const stem = slug(agent.name);
1807
+ const base = purpose ? `${stem}-${slug(purpose)}` : undefined;
1906
1808
  const instancesDir = join(agent._dir, "instances");
1907
1809
  const own = existsSync(instancesDir) ? readdirSync(instancesDir) : [];
1908
1810
  const instances = deploymentInstanceHomes(root), souls = deploymentSoulNames(root, prepared);
@@ -1913,7 +1815,7 @@ function deriveInstanceName(root, agent, purpose, prepared) {
1913
1815
  return n;
1914
1816
  }
1915
1817
  let i = own.length + 1, n;
1916
- do { n = `${agent.name}-${i++}`; } while (taken(n));
1818
+ do { n = `${stem}-${i++}`; } while (taken(n));
1917
1819
  return n;
1918
1820
  }
1919
1821
 
@@ -1973,6 +1875,19 @@ export function resolveClaudeBinary(contextDir) {
1973
1875
  * claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
1974
1876
  * codex: translate openai/openai-codex entries to native ids, otherwise use its default.
1975
1877
  * Probe failures: first entry wins (pi errors loudly at launch). */
1878
+ /** instance.json `modelFrom` (feature desktop-facts): where the model a home runs came from — "soul" (the
1879
+ * soul's preference), "spawn" / "start" (an explicit --model then), "launch-config", "harness-default" (the
1880
+ * harness's native model). A recorded model keeps `prior`. From resolveLaunchSelection's `modelSource`. */
1881
+ export function modelFromOf(modelSource, { at = "spawn", prior = null } = {}) {
1882
+ if (typeof modelSource !== "string") return prior ?? null;
1883
+ if (modelSource === "explicit") return at;
1884
+ if (modelSource === "soul default") return "soul";
1885
+ if (modelSource.startsWith("launch-config ")) return "launch-config";
1886
+ if (modelSource === "recorded") return prior ?? null;
1887
+ if (modelSource.startsWith("native default")) return "harness-default";
1888
+ return prior ?? null;
1889
+ }
1890
+
1976
1891
  export function resolveModelPreference(model, harness = "pi") {
1977
1892
  const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
1978
1893
  if (harness === "codex") {
@@ -2545,7 +2460,7 @@ function* spawnBody(root, agent, o = {}) {
2545
2460
  if (o.name !== undefined) instance = explicitInstanceName(o.name);
2546
2461
  else {
2547
2462
  instance = o.instance || nextInstanceName(root, agent, o.purpose, o.prepared);
2548
- if (!instance.startsWith(agent.name)) instance = `${agent.name}-${slug(instance)}`;
2463
+ if (!instance.startsWith(agent.name) && !instance.startsWith(slug(agent.name))) instance = `${agent.name}-${slug(instance)}`;
2549
2464
  instance = slug(instance);
2550
2465
  if (instance.length > MAX_INSTANCE_NAME) throw nameTooLong(instance, "pass a shorter instance name");
2551
2466
  }
@@ -2807,7 +2722,9 @@ function* spawnBody(root, agent, o = {}) {
2807
2722
  }
2808
2723
 
2809
2724
  const home = join(agent._dir, "instances", instance);
2810
- if (existsSync(home)) throw new Error(`instance already exists: ${home}`);
2725
+ // A home that exists already is the same refusal as losing the exclusive mkdir below (a
2726
+ // concurrent apply that got there first, or anything else): E_PLACEMENT_TAKEN, nothing created.
2727
+ if (existsSync(home)) throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home}; nothing was created by this call`), { instance, home });
2811
2728
  // AUTHORITATIVE placement check, on the DESTINATION rather than on lexical
2812
2729
  // paths, immediately before the first side effect. The earlier root/agent-dir
2813
2730
  // checks are lexical and can be walked around by a symlink anywhere along the
@@ -2849,7 +2766,7 @@ function* spawnBody(root, agent, o = {}) {
2849
2766
  // reads the soul, config chain and capability content), so resolving it here
2850
2767
  // lets every "declared but missing" failure happen with zero side effects to
2851
2768
  // roll back — no home, no worktree, no identity, no tmux window.
2852
- // Capability-defined agents carry _soulDir (read-only soul inside the package).
2769
+ // A workspace soul carries _soulDir (its per-commit soul cache: findAgentAt).
2853
2770
  const soulDir = agent._soulDir || soulOf(agent._dir);
2854
2771
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind, o.prepared);
2855
2772
  const resolvedCfg = composition.resolved;
@@ -3033,13 +2950,7 @@ function* spawnBody(root, agent, o = {}) {
3033
2950
  materializeOutcome = yield () => (o.materialize ?? materializePreparedDefault)({ ...o.prepared, soulAgentsMd: composition.text, soulDir }, home);
3034
2951
  const rows = (typeof o.prepared.toCapabilityRows === "function" ? o.prepared.toCapabilityRows : toCapabilityRows)(o.prepared.resolution, home);
3035
2952
  if (!Array.isArray(rows)) throw new Error("toCapabilityRows returned no rows");
3036
- // A capability agent runs no provider hook — not even its providing module's
3037
- // (lead decision c3 Q1: a harvester never registers as a knowledge source or
3038
- // gets a messaging identity). Its rows keep everything else.
3039
- for (const row of rows) {
3040
- if (agent.kind === "capability") { row.hooks = {}; row.requiredHooks = []; }
3041
- else row.hooks = materializedHookCommands(row, home);
3042
- }
2953
+ for (const row of rows) row.hooks = materializedHookCommands(row, home);
3043
2954
  resolvedCfg.capabilities = rows;
3044
2955
  // S1: the capability blocks materialize appended are part of the composed
3045
2956
  // instructions. Read the markers back from the AGENTS.md that was WRITTEN
@@ -3270,14 +3181,22 @@ function* spawnBody(root, agent, o = {}) {
3270
3181
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
3271
3182
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
3272
3183
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
3273
- const preparedSoulId = o.prepared ? workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name) : undefined;
3184
+ const preparedSoulId = o.prepared ? preparedSoulIdOf(o.prepared.soulEntry) : undefined;
3185
+ // A trigger's event (lib/triggers.mjs): a private copy in the home, named to hooks and the harness
3186
+ // as OATS_TRIGGER_EVENT_FILE. Its PR title/body are never in the task: the soul reads them from here.
3187
+ let triggerEventFile = null;
3188
+ if (o.triggerEvent && typeof o.triggerEvent === "object") {
3189
+ triggerEventFile = join(home, ".oats", TRIGGER_EVENT_FILE);
3190
+ mkdirSync(dirname(triggerEventFile), { recursive: true });
3191
+ writeFileSync(triggerEventFile, JSON.stringify(o.triggerEvent, null, 2) + "\n", { mode: 0o600 });
3192
+ }
3274
3193
  // Hooks read the soul the HOME links (the per-commit directory for a workspace
3275
3194
  // soul), never the swappable agents/<name>/soul pointer: a provider that pins a
3276
3195
  // path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
3277
3196
  const hookRes = runLifecycleHooks("spawn", {
3278
3197
  home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
3279
3198
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
3280
- extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent" },
3199
+ extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent", ...(triggerEventFile ? { OATS_TRIGGER_EVENT_FILE: triggerEventFile } : {}) },
3281
3200
  });
3282
3201
  warnings.push(...hookRes.warnings);
3283
3202
  // Which capability hooks RAN (in order) and how each ended — recorded on the
@@ -3498,6 +3417,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3498
3417
  hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
3499
3418
  prompt: LAUNCH_PROMPT,
3500
3419
  };
3420
+ if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3501
3421
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
3502
3422
 
3503
3423
  // Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
@@ -3506,7 +3426,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3506
3426
  const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
3507
3427
  const meta = {
3508
3428
  agent: agent.name, kind: agent.kind || "persistent", instance, home, soulDir: homeSoulTarget,
3509
- repo: repoAbs, work, branch, harness, model: model || undefined,
3429
+ repo: repoAbs, work, branch, harness, model: model || undefined, modelFrom: modelFromOf(launchSelection.modelSource, { at: "spawn" }) ?? undefined,
3510
3430
  ...(yolo !== undefined ? { yolo } : {}),
3511
3431
  parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
3512
3432
  siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
@@ -3514,6 +3434,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3514
3434
  relativeTo: relation ? relativeTo : undefined,
3515
3435
  spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
3516
3436
  policy: { childSpawns: ownChildPolicy },
3437
+ ...(triggerEventFile ? { trigger: { id: o.triggerEvent.trigger, key: o.triggerEvent.key ?? null, source: o.triggerEvent.source, repo: o.triggerEvent.repo, number: o.triggerEvent.number, url: o.triggerEvent.url ?? null, event: o.triggerEvent.event, headSha: o.triggerEvent.headSha ?? null, observedAt: o.triggerEvent.observedAt ?? null, eventFile: triggerEventFile } } : {}),
3517
3438
  // K6c: a decision-bound spawn records what bound it, so a retry with the
3518
3439
  // same key replays this receipt instead of spawning again.
3519
3440
  // The FULL bound decision (placement + effective), exactly as the fence
@@ -3523,7 +3444,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3523
3444
  capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
3524
3445
  capabilities: resolvedCfg.capabilities.map((cap) => ({
3525
3446
  id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
3526
- settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
3447
+ settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {}, provenance: cap.provenance, skills: cap.skills || [],
3527
3448
  hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
3528
3449
  ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
3529
3450
  ...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
@@ -3568,7 +3489,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3568
3489
  },
3569
3490
  },
3570
3491
  capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
3571
- id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
3492
+ id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
3572
3493
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
3573
3494
  missingRequires: cap.missingRequires, trust: cap.trust,
3574
3495
  executable: cap.executable,
@@ -3582,7 +3503,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3582
3503
  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 */ }
3583
3504
  // M5/3a: the workspace's name and the deployment directory are recorded, so a home
3584
3505
  // answers them (inspect/operation run --home, OATS_WORKSPACE_NAME) without discovery.
3585
- meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))] }, layers: layerRows(o.prepared.resolution) };
3506
+ meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: preparedSoulIdOf(o.prepared.soulEntry), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))], ...(typeof o.prepared.soulEntry.package === "string" ? { name: o.prepared.soulEntry.name, qualifiedName: o.prepared.soulEntry.qualifiedName, package: { id: o.prepared.soulEntry.package, version: o.prepared.soulEntry.version, commit: o.prepared.soulEntry.commit, digest: o.prepared.soulEntry.digest, path: o.prepared.soulEntry.path } } : {}) }, layers: layerRows(o.prepared.resolution) };
3586
3507
  // Teams contract (decision 6): the eligible teams at spawn, recorded as EVIDENCE beside
3587
3508
  // `providers` (never inside that capability-keyed map). A home's hooks and operations get
3588
3509
  // the LIVE set (liveTeams); this is what they fall back to when discovery cannot answer.
@@ -3730,6 +3651,12 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3730
3651
  catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
3731
3652
  }
3732
3653
  const identity = servedIdentityOf(meta);
3654
+ // Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
3655
+ // else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
3656
+ // messaging identity's address (or alias) its capability recorded, null otherwise; `modelFrom` — null
3657
+ // for a home spawned before it was recorded.
3658
+ const lastStart = Array.isArray(meta.restarts) && meta.restarts.length ? meta.restarts[meta.restarts.length - 1]?.startedAt ?? null : null;
3659
+ const facts = { startedAt: lastStart ?? (meta.launched ? meta.createdAt ?? null : null), identityAddress: identity ? identity.address ?? identity.alias ?? null : null, modelFrom: meta.modelFrom ?? null };
3733
3660
  // The home IS the directory enumerated here, and the instance its name:
3734
3661
  // a file inside it cannot relocate or rename itself in the roster (every
3735
3662
  // consumer acting on `home` — retire, inspect --home, the Desktop's file
@@ -3739,7 +3666,7 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3739
3666
  ...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
3740
3667
  ...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
3741
3668
  };
3742
- return { ...meta, ...claims, home, instance: e.name, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3669
+ return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3743
3670
 
3744
3671
  });
3745
3672
  };
@@ -3926,7 +3853,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3926
3853
  ...(sessionTarget ? { sessionTarget } : {}),
3927
3854
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
3928
3855
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
3929
- id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
3856
+ id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
3930
3857
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
3931
3858
  missingRequires: cap.missingRequires,
3932
3859
  trust: cap.trust, executable: cap.executable,
@@ -4523,7 +4450,9 @@ export function capturedProviders(meta, frozen) {
4523
4450
  return ids.map((id) => {
4524
4451
  const contribution = contributions.find((c) => c.capability === id) || null;
4525
4452
  const binding = bindings.find((b) => b.id === id) || null;
4526
- return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {} };
4453
+ // The origins travel with the settings they describe (the contribution's, else the binding's).
4454
+ const settingsOrigins = (contribution?.settings ? contribution.settingsOrigins : binding?.settingsOrigins) ?? {};
4455
+ return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {}, settingsOrigins };
4527
4456
  });
4528
4457
  }
4529
4458
  /** Capability contributions for a start of an existing home: the recorded
@@ -4551,7 +4480,7 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
4551
4480
  const trust = capabilityTrust(manifest);
4552
4481
  if (!trust.trusted) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer trusted in the scope (${trust.reason || "not trusted"}); respawn the instance; nothing was stopped`);
4553
4482
  const hooks = manifestHookCommands(manifest);
4554
- if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
4483
+ if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, settingsOrigins: p.settingsOrigins, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
4555
4484
  }
4556
4485
  if (withLaunchHook.length) {
4557
4486
  const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...extraEnv } });
@@ -4653,7 +4582,7 @@ export function startInstanceSession(home, o = {}) {
4653
4582
  // The independent receipt first (retire and session consult it), then the
4654
4583
  // mutable metadata; both tmp+rename. A failure between them is what the
4655
4584
  // pending receipt exists for.
4656
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
4585
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom }, clearPending = true) => {
4657
4586
  checkRoots();
4658
4587
  const baselinePath = retirementBaselinePath(realHome);
4659
4588
  let baseline;
@@ -4667,7 +4596,7 @@ export function startInstanceSession(home, o = {}) {
4667
4596
  const recorded = meta.startId === id;
4668
4597
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
4669
4598
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
4670
- const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
4599
+ const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1), ...(modelFrom ? { modelFrom } : {}),
4671
4600
  ...(launch ? { launch } : {}), ...(newHarness ? { harness: newHarness } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
4672
4601
  // Launch-hook meta lands per capability over the spawn's record; a hook
4673
4602
  // that answered without meta keeps its previous entry (retire reads it).
@@ -4752,7 +4681,7 @@ export function startInstanceSession(home, o = {}) {
4752
4681
  // preflight happens here, before anything is observed or stopped.
4753
4682
  const selected = o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
4754
4683
  const hasRecipe = meta.launch && typeof meta.launch === "object";
4755
- let launchPlan = null;
4684
+ let launchPlan = null, explicitModelFrom = null;
4756
4685
  if (selected || hasRecipe) {
4757
4686
  // Every start of a home with a recipe (ordinary, model-only, or under a
4758
4687
  // selection) goes through the one planner: recipe shape, the recorded
@@ -4765,13 +4694,13 @@ export function startInstanceSession(home, o = {}) {
4765
4694
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, teamsSource: o.teamsSource });
4766
4695
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
4767
4696
  const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
4768
- launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
4697
+ launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }), ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
4769
4698
  command = launchPlan.command; model = launchPlan.model;
4770
4699
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
4771
4700
  const resolved = resolveModelPreference(String(o.model), harness);
4772
4701
  if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by harness ${harness}; give a ${harness} model id`);
4773
4702
  command = withLaunchModel(command, resolved);
4774
- model = resolved;
4703
+ model = resolved; explicitModelFrom = "start";
4775
4704
  } else parseLaunchCommand(command);
4776
4705
  // References recorded for this home must resolve on this host on every
4777
4706
  // start path, and the source variables go to the pane, not the command.
@@ -4781,8 +4710,8 @@ export function startInstanceSession(home, o = {}) {
4781
4710
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4782
4711
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4783
4712
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4784
- const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo,
4785
- ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}) } : {};
4713
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
4714
+ ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}) } : (explicitModelFrom ? { modelFrom: explicitModelFrom } : {});
4786
4715
  let target = receipt.target;
4787
4716
  let state = { present: false, state: "not-launched" };
4788
4717
  let serverGone = false;
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
18
+ import { agentDirOf, discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
21
21
  import { loadLocal } from "./workspace.mjs";
@@ -94,7 +94,7 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
94
94
  // The derived deployment exactly — never an oats-local.yaml found further up.
95
95
  const found = loadLocal(deployment);
96
96
  if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
97
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
97
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
98
98
  }
99
99
  catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
100
100
  }
@@ -120,7 +120,9 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
120
120
  soul: { name: meta.agent, repoKey: ws.soul?.repoKey ?? null, commit: ws.soul?.commit ?? null, team: ws.soul?.team ?? null, external, path: null, soulDir, definition, problems: [...problems, ...loadProblems] },
121
121
  workspace: { key: ws.key ?? null, name: typeof ws.name === "string" ? ws.name : discovery?.workspace?.name ?? null, deployment, commit: ws.commit ?? null, standalone: ws.standalone === true },
122
122
  modules: Object.keys(modules).sort().map((name) => ({ name, from: modules[name]?.from ?? null, manifest: manifests[name] ?? null, dir: join(realHome, ".oats", "modules", name) })),
123
- payloads: obj(meta.providers) ? meta.providers : {}, slots, slotsFrom: Object.fromEntries(LAYERS.map((l) => [l, obj(ws.layers?.[l]) ? ws.layers[l].from ?? null : null])), discovery, discoveryError, resolutionError: null,
123
+ payloads: obj(meta.providers) ? meta.providers : {},
124
+ payloadOrigins: Object.fromEntries((Array.isArray(meta.capabilities) ? meta.capabilities : []).filter((c) => c && typeof c.id === "string").map((c) => [c.id, obj(c.settingsOrigins) ? c.settingsOrigins : {}])),
125
+ slots, slotsFrom: Object.fromEntries(LAYERS.map((l) => [l, obj(ws.layers?.[l]) ? ws.layers[l].from ?? null : null])), discovery, discoveryError, resolutionError: null,
124
126
  lock: null, prepared: null,
125
127
  };
126
128
  }
@@ -140,13 +142,13 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
140
142
  let discovery = prepared?.discovery ?? null, soulEntry = prepared?.soulEntry ?? null;
141
143
  if (!prepared) {
142
144
  // Still name the soul (and its member) when its resolution is refused.
143
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
145
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
144
146
  soulEntry = findSoulEntry(discovery, soul);
145
147
  }
146
148
  const res = prepared?.resolution;
147
149
  // A refused resolution still names its eligible teams: the labels and the workspace are known.
148
150
  const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
149
- const cached = soulEntry?.commit ? join(deployment, "agents", soulEntry.name, "souls", String(soulEntry.commit).slice(0, 12)) : null;
151
+ const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
150
152
  return {
151
153
  kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
152
154
  subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },
@@ -154,7 +156,8 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
154
156
  soulDir: cached && existsSync(join(cached, "soul.yaml")) ? cached : null, definition: soulEntry.definition ?? null, problems: [] },
155
157
  workspace: { key: discovery?.key ?? null, name: discovery?.workspace?.name ?? null, deployment, commit: discovery?.commit ?? null, standalone: discovery?.standalone === true },
156
158
  modules: (res?.modules || []).map((m) => ({ name: m.name, from: m.from ?? null, manifest: m.manifest ?? null, dir: null, module: m })).sort((a, b) => a.name.localeCompare(b.name)),
157
- payloads: obj(res?.payloads) ? res.payloads : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
159
+ payloads: obj(res?.payloads) ? res.payloads : {}, payloadOrigins: obj(res?.payloadOrigins) ? res.payloadOrigins : {}, slots: obj(res?.slots) ? res.slots : Object.fromEntries(LAYERS.map((l) => [l, null])), slotsFrom: obj(res?.slotsFrom) ? res.slotsFrom : {},
160
+ capabilitiesFrom: obj(res?.capabilitiesFrom) ? res.capabilitiesFrom : {}, turnedOff: Array.isArray(res?.turnedOff) ? res.turnedOff : [],
158
161
  discovery, discoveryError: null, resolutionError, lock: prepared?.lock ?? null, prepared,
159
162
  };
160
163
  }
@@ -184,7 +187,9 @@ function capabilityRows(t) {
184
187
  else if (op.context === "home" && !t.home) reason = "needs a running home (--home)";
185
188
  return { ...op, argv: [m.command ?? null, op.command], available: !reason, reason };
186
189
  });
187
- return { id: name, version: m.version ?? null, layer: m.layer ?? null, command: m.command ?? null, from, dir,
190
+ // composedFrom (feature desktop-facts): where the soul's composition took it from — "workspace" |
191
+ // "team:<label>" | "soul" (layers-from vocabulary); null for a home (not recorded at spawn). `from` is the module's origin.
192
+ return { id: name, version: m.version ?? null, layer: m.layer ?? null, command: m.command ?? null, from, composedFrom: t.capabilitiesFrom?.[name] ?? null, dir,
188
193
  settings: obj(t.payloads[name]) ? t.payloads[name] : {}, declares: declaredSettings(m), compatibility: compatibilityRow(name, m), missingRequires: missing, operations };
189
194
  });
190
195
  }
@@ -207,6 +212,9 @@ export function inspectDocument(t, { kernel }) {
207
212
  return {
208
213
  operationsApi: INSPECT_OPERATIONS_API, kernel, subject: t.subject, workspace: t.workspace,
209
214
  souls: [soulRow(t)], layers, capabilities,
215
+ // The capabilities the soul turned off (feature desktop-facts): its `off` over a workspace/team entry
216
+ // (reason "off"), or a `<slot>: none` that dropped a workspace default (reason "slot-none", slot).
217
+ capabilitiesOff: (t.turnedOff || []).map(({ name, reason, slot, overrides }) => ({ id: name, off: true, from: "soul", reason, ...(slot ? { slot } : {}), overrides })),
210
218
  // Teams contract item 7 (feature `teams`): the ELIGIBLE teams, one per soul label in order —
211
219
  // live for a home (`teamsSource: "recorded"` when its workspace could not be read now).
212
220
  teams: t.teams ?? null, teamsSource: t.teamsSource ?? null,
@@ -234,7 +242,7 @@ const item = (subject, status, { required = true, reason = null, producer = "ker
234
242
  function providerEnv(t, capability, settings) {
235
243
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(OATS_|OAS_|PI_)/.test(k)));
236
244
  Object.assign(env, teamEnv({ workspace: { key: t.workspace.key, name: t.workspace.name, deployment: t.deployment, team: t.soul.team, slots: t.slots }, payloads: t.payloads, teams: t.teams, teamsSource: t.teamsSource }));
237
- return Object.assign(env, { OATS_CAPABILITY: capability, OATS_SETTINGS: JSON.stringify(settings), OATS_CLI_BIN: CLI_BIN, OATS_WORKSPACE: t.deployment,
245
+ return Object.assign(env, { OATS_CAPABILITY: capability, OATS_SETTINGS: JSON.stringify(settings), OATS_SETTINGS_ORIGINS: JSON.stringify(t.payloadOrigins?.[capability] ?? {}), OATS_CLI_BIN: CLI_BIN, OATS_WORKSPACE: t.deployment,
238
246
  ...(t.home ? { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home } : {}), OATS_AGENT: t.soul.name, ...(t.soul.soulDir ? { OATS_SOUL: t.soul.soulDir } : {}) });
239
247
  }
240
248
  /** An executable inside a module directory: both sides realpath'd (a symlink out of