@awebai/oats 0.28.0 → 0.29.1

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 (70) hide show
  1. package/bin/oats.mjs +298 -107
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
  3. package/capabilities/oats-okf/injects/okf.md +33 -33
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +2 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +1 -5
  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/okf-validate.mjs +123 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +28 -3
  11. package/capabilities/oats-okf/lib/stores.mjs +9 -4
  12. package/capabilities/oats-okf/lib/worker.mjs +82 -8
  13. package/capabilities/oats-okf/oats.json +14 -8
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
  16. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  17. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  18. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  19. package/capabilities/oats-okf-harvest/oats.json +26 -0
  20. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  21. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  22. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
  23. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  24. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  25. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  26. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  27. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  28. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  29. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  30. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  32. package/capabilities/oats-review/injects/review.md +3 -2
  33. package/capabilities/oats-review/oats.json +3 -4
  34. package/docs/capabilities.md +41 -9
  35. package/docs/capability-manifest.schema.json +0 -7
  36. package/docs/desktop-cli-api.md +277 -15
  37. package/docs/implementation.md +1 -1
  38. package/docs/knowledge-capability-authoring.md +8 -2
  39. package/docs/knowledge-reference/package-craft.md +8 -5
  40. package/docs/knowledge.md +101 -0
  41. package/docs/oats-local.schema.json +31 -1
  42. package/docs/official-catalog.md +7 -4
  43. package/docs/packages.md +11 -5
  44. package/docs/release-lane.md +1 -1
  45. package/docs/release-notes/v0.29.0.md +240 -0
  46. package/docs/release-notes/v0.29.1.md +79 -0
  47. package/docs/schedules.md +133 -5
  48. package/docs/souls-and-instances.md +4 -6
  49. package/docs/workspaces.md +11 -2
  50. package/lib/automations.mjs +369 -0
  51. package/lib/core.mjs +65 -154
  52. package/lib/instance-git.mjs +30 -1
  53. package/lib/instance-inspect.mjs +12 -4
  54. package/lib/instance-resolution.mjs +31 -182
  55. package/lib/materialize.mjs +5 -7
  56. package/lib/operator-dispatch.mjs +1 -2
  57. package/lib/packages.mjs +17 -0
  58. package/lib/remote.mjs +21 -1
  59. package/lib/resolve.mjs +51 -7
  60. package/lib/schedule.mjs +211 -41
  61. package/lib/triggers.mjs +182 -49
  62. package/lib/workspace.mjs +1 -1
  63. package/package-catalog.json +6 -4
  64. package/package.json +1 -1
  65. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  66. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  67. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  68. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  69. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  70. /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 });
@@ -1589,6 +1555,10 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1589
1555
  // a caller's extraEnv: those may point at a different executable.
1590
1556
  OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
1591
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 || {}),
1592
1562
  OATS_META: JSON.stringify(priorMeta[cap.id] || {}),
1593
1563
  },
1594
1564
  }).trim();
@@ -1605,7 +1575,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1605
1575
  // A launch hook's run is recorded even when its answer is empty: an
1606
1576
  // empty answer replaces what the provider contributed before.
1607
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)) {
1608
- 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() : [] });
1609
1579
  }
1610
1580
  if (o.env !== undefined) {
1611
1581
  if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn and launch, not ${event}`);
@@ -1694,91 +1664,6 @@ export function findAgentAt(root, name, soulDir) {
1694
1664
  return agent;
1695
1665
  }
1696
1666
 
1697
- /** Canonical capability-defined agents: a manifest's `agents: ["agents/reviewer"]`
1698
- * entries are package-relative soul directories (soul.yaml + AGENTS.md directly
1699
- * inside). They resolve when the capability is ACTIVE in the
1700
- * context; the soul stays read-only in the package (fresh identity every spawn —
1701
- * no long-term memory), while instances home under <root>/<name>/instances/ (the
1702
- * agent dir holds only instances/). */
1703
- function capabilityAgentMetadata(manifest, rel) {
1704
- const soulDir = manifestPath(manifest, rel);
1705
- const soulFile = manifestPath(manifest, join(rel, "soul.yaml"));
1706
- if (!soulDir || !soulFile) return undefined;
1707
- // Read only contained identity metadata to decide whether this provider owns
1708
- // the requested name. Full tree containment + trust happen after a match.
1709
- const soul = soulHarnessField(stripInternalAnnotations(withConfigFile(soulFile, () => parseYamlFlat(readFileSync(soulFile, "utf8")))), soulFile);
1710
- return { soulDir, soul, name: soul.name || basename(soulDir) };
1711
- }
1712
- /**
1713
- * Workspace model: a capability-defined agent (a package's `agents:` soul, e.g.
1714
- * OKF's memory-harvest worker) resolves from a MATERIALIZED module — the copy the
1715
- * requesting instance already carries under <home>/.oats/modules/<cap>/. Looks in
1716
- * `anchorHome` first (the --parent / --relative-to instance), then every instance
1717
- * home under `root`; the first module declaring an agent of that name wins.
1718
- * → the same shape findCapabilityAgent returns, plus `_manifestSource` (the home
1719
- * whose modules supplied it) so skill/inject lookups read that copy.
1720
- */
1721
- export function findModuleCapabilityAgent(root, name, { anchorHome = null } = {}) {
1722
- if (typeof name !== "string" || !name) return undefined;
1723
- const homes = [];
1724
- if (anchorHome) homes.push(anchorHome);
1725
- if (root && existsSync(root)) {
1726
- for (const a of readdirSync(root, { withFileTypes: true })) {
1727
- if (!a.isDirectory() || a.name.startsWith(".")) continue;
1728
- const inst = join(root, a.name, "instances");
1729
- if (!existsSync(inst)) continue;
1730
- for (const i of readdirSync(inst, { withFileTypes: true })) if (i.isDirectory() && !i.name.startsWith(".")) homes.push(join(inst, i.name));
1731
- }
1732
- }
1733
- const seen = new Set();
1734
- const failures = [];
1735
- for (const home of homes) {
1736
- const real = (() => { try { return realpathSync(home); } catch { return null; } })();
1737
- if (!real || seen.has(real)) continue;
1738
- seen.add(real);
1739
- const modules = instanceModulesRoot(real);
1740
- if (!modules) continue;
1741
- for (const [id, manifest] of Object.entries(capabilityManifests(real))) {
1742
- for (const rel of manifest?.agents || []) {
1743
- let meta;
1744
- try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
1745
- if (!meta || meta.name !== name) continue;
1746
- try {
1747
- assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
1748
- return {
1749
- ...meta.soul, name,
1750
- kind: "capability", capability: id,
1751
- _dir: join(root, name),
1752
- _soulDir: meta.soulDir,
1753
- _manifestSource: real,
1754
- _module: manifest._module,
1755
- };
1756
- } catch (e) { failures.push(e); }
1757
- }
1758
- }
1759
- }
1760
- if (failures.length) throw failures[0];
1761
- return undefined;
1762
- }
1763
- /**
1764
- * A capability-defined agent from ONE capability directory (a package tree the
1765
- * workspace resolver fetched into the deployment's module store). The manifest is
1766
- * read from `dir`, trusted by construction (the workspace declares the package; the lock pins it), and
1767
- * the agent record points its skill/inject lookups at that store entry.
1768
- */
1769
- export function capabilityAgentFromDir(dir, name, root, { module = null } = {}) {
1770
- const manifest = loadManifestAt(dir, `module:${dir}`);
1771
- if (!manifest) return undefined;
1772
- manifest._module = module;
1773
- for (const rel of manifest.agents || []) {
1774
- let meta;
1775
- try { meta = capabilityAgentMetadata(manifest, rel); } catch { continue; }
1776
- if (!meta || meta.name !== name) continue;
1777
- assertCapabilityTreeContained(manifest, meta.soulDir, "agent");
1778
- return { ...meta.soul, name, kind: "capability", capability: manifest.capability, _dir: join(root, name), _soulDir: meta.soulDir, _manifestSource: dir, _manifest: manifest, _module: module };
1779
- }
1780
- return undefined;
1781
- }
1782
1667
  export function listAgents(root) {
1783
1668
  const agents = [];
1784
1669
  const scan = (base, kind) => {
@@ -1906,11 +1791,20 @@ const nameTooLong = (name, remedy) => oatsError("E_INSTANCE_NAME_INVALID", `inst
1906
1791
  * final name, suffix included, is capped at MAX_INSTANCE_NAME. */
1907
1792
  function nextInstanceName(root, agent, purpose, prepared) {
1908
1793
  const n = deriveInstanceName(root, agent, purpose, prepared);
1909
- 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`);
1910
- return n;
1911
- }
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.
1912
1805
  function deriveInstanceName(root, agent, purpose, prepared) {
1913
- const base = purpose ? `${agent.name}-${slug(purpose)}` : undefined;
1806
+ const stem = slug(agent.name);
1807
+ const base = purpose ? `${stem}-${slug(purpose)}` : undefined;
1914
1808
  const instancesDir = join(agent._dir, "instances");
1915
1809
  const own = existsSync(instancesDir) ? readdirSync(instancesDir) : [];
1916
1810
  const instances = deploymentInstanceHomes(root), souls = deploymentSoulNames(root, prepared);
@@ -1921,7 +1815,7 @@ function deriveInstanceName(root, agent, purpose, prepared) {
1921
1815
  return n;
1922
1816
  }
1923
1817
  let i = own.length + 1, n;
1924
- do { n = `${agent.name}-${i++}`; } while (taken(n));
1818
+ do { n = `${stem}-${i++}`; } while (taken(n));
1925
1819
  return n;
1926
1820
  }
1927
1821
 
@@ -1981,6 +1875,19 @@ export function resolveClaudeBinary(contextDir) {
1981
1875
  * claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
1982
1876
  * codex: translate openai/openai-codex entries to native ids, otherwise use its default.
1983
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
+
1984
1891
  export function resolveModelPreference(model, harness = "pi") {
1985
1892
  const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
1986
1893
  if (harness === "codex") {
@@ -2553,7 +2460,7 @@ function* spawnBody(root, agent, o = {}) {
2553
2460
  if (o.name !== undefined) instance = explicitInstanceName(o.name);
2554
2461
  else {
2555
2462
  instance = o.instance || nextInstanceName(root, agent, o.purpose, o.prepared);
2556
- 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)}`;
2557
2464
  instance = slug(instance);
2558
2465
  if (instance.length > MAX_INSTANCE_NAME) throw nameTooLong(instance, "pass a shorter instance name");
2559
2466
  }
@@ -2815,7 +2722,9 @@ function* spawnBody(root, agent, o = {}) {
2815
2722
  }
2816
2723
 
2817
2724
  const home = join(agent._dir, "instances", instance);
2818
- 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 });
2819
2728
  // AUTHORITATIVE placement check, on the DESTINATION rather than on lexical
2820
2729
  // paths, immediately before the first side effect. The earlier root/agent-dir
2821
2730
  // checks are lexical and can be walked around by a symlink anywhere along the
@@ -2857,7 +2766,7 @@ function* spawnBody(root, agent, o = {}) {
2857
2766
  // reads the soul, config chain and capability content), so resolving it here
2858
2767
  // lets every "declared but missing" failure happen with zero side effects to
2859
2768
  // roll back — no home, no worktree, no identity, no tmux window.
2860
- // Capability-defined agents carry _soulDir (read-only soul inside the package).
2769
+ // A workspace soul carries _soulDir (its per-commit soul cache: findAgentAt).
2861
2770
  const soulDir = agent._soulDir || soulOf(agent._dir);
2862
2771
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind, o.prepared);
2863
2772
  const resolvedCfg = composition.resolved;
@@ -3041,13 +2950,7 @@ function* spawnBody(root, agent, o = {}) {
3041
2950
  materializeOutcome = yield () => (o.materialize ?? materializePreparedDefault)({ ...o.prepared, soulAgentsMd: composition.text, soulDir }, home);
3042
2951
  const rows = (typeof o.prepared.toCapabilityRows === "function" ? o.prepared.toCapabilityRows : toCapabilityRows)(o.prepared.resolution, home);
3043
2952
  if (!Array.isArray(rows)) throw new Error("toCapabilityRows returned no rows");
3044
- // A capability agent runs no provider hook — not even its providing module's
3045
- // (lead decision c3 Q1: a harvester never registers as a knowledge source or
3046
- // gets a messaging identity). Its rows keep everything else.
3047
- for (const row of rows) {
3048
- if (agent.kind === "capability") { row.hooks = {}; row.requiredHooks = []; }
3049
- else row.hooks = materializedHookCommands(row, home);
3050
- }
2953
+ for (const row of rows) row.hooks = materializedHookCommands(row, home);
3051
2954
  resolvedCfg.capabilities = rows;
3052
2955
  // S1: the capability blocks materialize appended are part of the composed
3053
2956
  // instructions. Read the markers back from the AGENTS.md that was WRITTEN
@@ -3523,7 +3426,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3523
3426
  const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
3524
3427
  const meta = {
3525
3428
  agent: agent.name, kind: agent.kind || "persistent", instance, home, soulDir: homeSoulTarget,
3526
- repo: repoAbs, work, branch, harness, model: model || undefined,
3429
+ repo: repoAbs, work, branch, harness, model: model || undefined, modelFrom: modelFromOf(launchSelection.modelSource, { at: "spawn" }) ?? undefined,
3527
3430
  ...(yolo !== undefined ? { yolo } : {}),
3528
3431
  parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
3529
3432
  siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
@@ -3541,7 +3444,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3541
3444
  capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
3542
3445
  capabilities: resolvedCfg.capabilities.map((cap) => ({
3543
3446
  id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
3544
- settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
3447
+ settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {}, provenance: cap.provenance, skills: cap.skills || [],
3545
3448
  hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
3546
3449
  ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
3547
3450
  ...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
@@ -3586,7 +3489,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3586
3489
  },
3587
3490
  },
3588
3491
  capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
3589
- 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 ?? {},
3590
3493
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
3591
3494
  missingRequires: cap.missingRequires, trust: cap.trust,
3592
3495
  executable: cap.executable,
@@ -3748,6 +3651,12 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3748
3651
  catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
3749
3652
  }
3750
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 };
3751
3660
  // The home IS the directory enumerated here, and the instance its name:
3752
3661
  // a file inside it cannot relocate or rename itself in the roster (every
3753
3662
  // consumer acting on `home` — retire, inspect --home, the Desktop's file
@@ -3757,7 +3666,7 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3757
3666
  ...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
3758
3667
  ...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
3759
3668
  };
3760
- 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 } : {}) };
3761
3670
 
3762
3671
  });
3763
3672
  };
@@ -3944,7 +3853,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3944
3853
  ...(sessionTarget ? { sessionTarget } : {}),
3945
3854
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
3946
3855
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
3947
- 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 ?? {},
3948
3857
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
3949
3858
  missingRequires: cap.missingRequires,
3950
3859
  trust: cap.trust, executable: cap.executable,
@@ -4541,7 +4450,9 @@ export function capturedProviders(meta, frozen) {
4541
4450
  return ids.map((id) => {
4542
4451
  const contribution = contributions.find((c) => c.capability === id) || null;
4543
4452
  const binding = bindings.find((b) => b.id === id) || null;
4544
- 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 };
4545
4456
  });
4546
4457
  }
4547
4458
  /** Capability contributions for a start of an existing home: the recorded
@@ -4569,7 +4480,7 @@ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, c
4569
4480
  const trust = capabilityTrust(manifest);
4570
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`);
4571
4482
  const hooks = manifestHookCommands(manifest);
4572
- 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: [] });
4573
4484
  }
4574
4485
  if (withLaunchHook.length) {
4575
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 } });
@@ -4671,7 +4582,7 @@ export function startInstanceSession(home, o = {}) {
4671
4582
  // The independent receipt first (retire and session consult it), then the
4672
4583
  // mutable metadata; both tmp+rename. A failure between them is what the
4673
4584
  // pending receipt exists for.
4674
- 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) => {
4675
4586
  checkRoots();
4676
4587
  const baselinePath = retirementBaselinePath(realHome);
4677
4588
  let baseline;
@@ -4685,7 +4596,7 @@ export function startInstanceSession(home, o = {}) {
4685
4596
  const recorded = meta.startId === id;
4686
4597
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
4687
4598
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
4688
- 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 } : {}),
4689
4600
  ...(launch ? { launch } : {}), ...(newHarness ? { harness: newHarness } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
4690
4601
  // Launch-hook meta lands per capability over the spawn's record; a hook
4691
4602
  // that answered without meta keeps its previous entry (retire reads it).
@@ -4770,7 +4681,7 @@ export function startInstanceSession(home, o = {}) {
4770
4681
  // preflight happens here, before anything is observed or stopped.
4771
4682
  const selected = o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
4772
4683
  const hasRecipe = meta.launch && typeof meta.launch === "object";
4773
- let launchPlan = null;
4684
+ let launchPlan = null, explicitModelFrom = null;
4774
4685
  if (selected || hasRecipe) {
4775
4686
  // Every start of a home with a recipe (ordinary, model-only, or under a
4776
4687
  // selection) goes through the one planner: recipe shape, the recorded
@@ -4783,13 +4694,13 @@ export function startInstanceSession(home, o = {}) {
4783
4694
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, teamsSource: o.teamsSource });
4784
4695
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
4785
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 });
4786
- 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 } : {}) };
4787
4698
  command = launchPlan.command; model = launchPlan.model;
4788
4699
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
4789
4700
  const resolved = resolveModelPreference(String(o.model), harness);
4790
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`);
4791
4702
  command = withLaunchModel(command, resolved);
4792
- model = resolved;
4703
+ model = resolved; explicitModelFrom = "start";
4793
4704
  } else parseLaunchCommand(command);
4794
4705
  // References recorded for this home must resolve on this host on every
4795
4706
  // start path, and the source variables go to the pane, not the command.
@@ -4799,8 +4710,8 @@ export function startInstanceSession(home, o = {}) {
4799
4710
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4800
4711
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4801
4712
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4802
- const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo,
4803
- ...(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 } : {});
4804
4715
  let target = receipt.target;
4805
4716
  let state = { present: false, state: "not-launched" };
4806
4717
  let serverGone = false;
@@ -138,6 +138,33 @@ function remoteOf(work, branch) {
138
138
  if (!url) return null;
139
139
  return { name, url, ...parseRemoteUrl(url), source: configured && configured !== "." ? "branch-upstream" : "origin" };
140
140
  }
141
+ /** Per-file line counts (0.29.1), from ONE `git diff <captured HEAD> --numstat -z -M`: the working tree
142
+ * against the observed commit, staged and unstaged combined. That is the same baseline as the status letters
143
+ * and `oats instance diff`. An unborn tree counts against the empty tree (`hash-object` without -w writes nothing).
144
+ * → Map path → { additions, deletions, binary }, keyed by the NEW path for a rename; null when git failed.
145
+ * -z records: "A\tD\tpath\0", or "A\tD\t\0old\0new\0" for a rename; "-\t-" is a binary file. */
146
+ function lineCounts(work, headOid) {
147
+ const against = headOid ?? trim(git(work, ["hash-object", "-t", "tree", "/dev/null"], { allowFail: true }));
148
+ if (!against) return null;
149
+ const raw = git(work, ["diff", against, "--numstat", "-z", "-M", "--"], { allowFail: true });
150
+ if (raw === null) return null;
151
+ const counts = new Map(), recs = raw.split("\0");
152
+ for (let i = 0; i < recs.length;) {
153
+ const m = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(recs[i++]);
154
+ if (!m) continue;
155
+ let path = m[3];
156
+ if (path === "") { i++; path = recs[i++]; } // a rename: skip the old path, key the new one
157
+ if (typeof path !== "string" || !path) continue;
158
+ counts.set(path, m[1] === "-" ? { additions: null, deletions: null, binary: true } : { additions: Number(m[1]), deletions: Number(m[2]), binary: false });
159
+ }
160
+ return counts;
161
+ }
162
+ /** An entry's counts: untracked (no baseline; contents are not read) and submodules have none (all null). */
163
+ function countsOf(counts, entry) {
164
+ if (entry.kind === "untracked" || entry.submodule) return { additions: null, deletions: null, binary: null };
165
+ return counts?.get(entry.path) ?? { additions: null, deletions: null, binary: null };
166
+ }
167
+
141
168
  function fileId(revision, indexOid, entry) {
142
169
  return createHash("sha256").update(`${revision}\0${indexOid}\0${entry.kind}\0${entry.path}\0${entry.origPath ?? ""}`).digest("hex").slice(0, 24);
143
170
  }
@@ -164,7 +191,8 @@ export function observeInstanceGit(home) {
164
191
  const counts = mergeBase ? countRange(work, `${base.ref}...HEAD`) : null;
165
192
  baseComparison = { ref: base.ref, source: base.source, mergeBase, ahead: counts ? counts.right : null, behind: counts ? counts.left : null };
166
193
  }
167
- const files = entries.filter((e) => e.kind !== "ignored").map((e) => ({ id: fileId(revision, indexOid, e), ...e }));
194
+ const counts = lineCounts(work, headOid);
195
+ const files = entries.filter((e) => e.kind !== "ignored").map((e) => ({ id: fileId(revision, indexOid, e), ...e, ...countsOf(counts, e) }));
168
196
  const summary = { changed: 0, renamed: 0, copied: 0, unmerged: 0, untracked: 0 };
169
197
  for (const f of files) summary[f.kind]++;
170
198
  return {
@@ -178,6 +206,7 @@ export function observeInstanceGit(home) {
178
206
  notes: [
179
207
  ...(upstream.ref === null ? ["no upstream configured: upstream ahead/behind are unknown, not zero"] : []),
180
208
  ...(baseComparison.ref === null ? ["no default branch found (origin/HEAD, origin/main, origin/master, main, master): base comparison unknown"] : []),
209
+ ...(counts === null ? ["line counts unavailable (git diff --numstat failed): additions/deletions are unknown, not zero"] : []),
181
210
  ],
182
211
  };
183
212
  }
@@ -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
  }
@@ -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