total-recall-brain 3.40.7 → 3.41.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 (170) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  18. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  25. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  26. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  27. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  28. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  29. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  30. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  31. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  32. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  33. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  34. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  35. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  36. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  37. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  38. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  39. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  40. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  41. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  53. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  55. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  62. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  69. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  70. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  71. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  72. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  73. package/src/cli/brain.mjs +4 -2
  74. package/src/cli/context-stats.mjs +104 -0
  75. package/src/cli/context.mjs +8 -0
  76. package/src/cli/daemon.mjs +10 -1
  77. package/src/cli/export.mjs +3 -2
  78. package/src/cli/ingest-okf.mjs +12 -15
  79. package/src/cli/init.mjs +0 -85
  80. package/src/cli/lint.mjs +3 -2
  81. package/src/cli/plugin/copies.mjs +68 -0
  82. package/src/cli/plugin/create.mjs +22 -24
  83. package/src/cli/plugin/index.mjs +49 -3
  84. package/src/cli/plugin/search.mjs +19 -2
  85. package/src/cli/rebuild.mjs +2 -0
  86. package/src/cli/remember.mjs +23 -0
  87. package/src/cli/status.mjs +10 -3
  88. package/src/cli/update.mjs +3 -2
  89. package/src/core/action-predictor.mjs +161 -0
  90. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  91. package/src/core/app-deploy/source.mjs +16 -9
  92. package/src/core/automatic-brain-updates.mjs +6 -3
  93. package/src/core/brain-registry.mjs +19 -2
  94. package/src/core/brain-state.mjs +23 -0
  95. package/src/core/cli-commands.mjs +1 -0
  96. package/src/core/cli-maintenance.mjs +1 -1
  97. package/src/core/cli-semantic-index.mjs +1 -1
  98. package/src/core/command-surface.mjs +3 -3
  99. package/src/core/composed-command-resolution.mjs +21 -0
  100. package/src/core/config.mjs +84 -0
  101. package/src/core/confined-memory.mjs +93 -0
  102. package/src/core/context-telemetry.mjs +214 -0
  103. package/src/core/daemon-control.mjs +14 -43
  104. package/src/core/fast-recall.mjs +1 -1
  105. package/src/core/memory-brain.mjs +2 -0
  106. package/src/core/plugin-activation.mjs +38 -6
  107. package/src/core/plugin-bundle.mjs +54 -0
  108. package/src/core/plugin-catalog.mjs +1 -1
  109. package/src/core/plugin-context.mjs +1 -1
  110. package/src/core/plugin-contracts/card.mjs +69 -37
  111. package/src/core/plugin-loader.mjs +5 -6
  112. package/src/core/plugin-runner.mjs +13 -6
  113. package/src/core/plugin-runtime.mjs +5 -4
  114. package/src/core/plugin-setup.mjs +5 -3
  115. package/src/core/plugin-state-store.mjs +12 -1
  116. package/src/core/plugin-state.mjs +46 -5
  117. package/src/core/plugin-store.mjs +78 -3
  118. package/src/core/plugin-tasks.mjs +3 -1
  119. package/src/core/plugin-updates.mjs +117 -0
  120. package/src/core/plugin-vendor.mjs +10 -4
  121. package/src/core/project-brain.mjs +14 -41
  122. package/src/core/project-id.mjs +4 -1
  123. package/src/core/required-plugins.mjs +151 -13
  124. package/src/core/skills-registry.mjs +27 -3
  125. package/src/core/ssss-kernel-bridge.mjs +3 -1
  126. package/src/core/startup-health.mjs +66 -16
  127. package/src/core/surface.mjs +51 -44
  128. package/src/core/testing/plugin-fixture.mjs +69 -0
  129. package/src/core/vault-files.mjs +13 -0
  130. package/src/core/vault.mjs +4 -30
  131. package/src/server/index.mjs +13 -1
  132. package/src/server/invoke-decision-child.mjs +49 -0
  133. package/src/server/invoke-execution.mjs +87 -0
  134. package/src/server/invoke-policy.mjs +64 -0
  135. package/src/server/invoke-read-child.mjs +26 -0
  136. package/src/server/invoke-targets.mjs +49 -0
  137. package/src/server/invoke-write-child.mjs +19 -0
  138. package/src/server/routes/invoke.mjs +314 -0
  139. package/src/server/routes/plugins.mjs +1 -1
  140. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  141. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  142. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  143. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  144. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  145. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  146. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  147. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  148. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  152. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  153. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  156. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  157. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  158. package/scaffold/openwiki/README.md +0 -10
  159. package/scaffold/openwiki/architecture.md +0 -5
  160. package/scaffold/openwiki/ide.md +0 -10
  161. package/scaffold/openwiki/memory.md +0 -11
  162. package/scaffold/openwiki/secrets.md +0 -7
  163. package/scaffold/openwiki/skills.md +0 -7
  164. package/src/core/okf-adapter.mjs +0 -559
  165. package/templates/openwiki/README.md +0 -10
  166. package/templates/openwiki/architecture.md +0 -5
  167. package/templates/openwiki/ide.md +0 -10
  168. package/templates/openwiki/memory.md +0 -11
  169. package/templates/openwiki/secrets.md +0 -7
  170. package/templates/openwiki/skills.md +0 -7
@@ -1,12 +1,43 @@
1
- /** Required native capability installation checks; never opens credential state. */
1
+ /**
2
+ * Required native capability installation checks; never opens credential state.
3
+ *
4
+ * The nine defaults live in ONE shared folder per machine — the global brain.
5
+ * A project brain never receives its own copy: discovery already falls back to
6
+ * the shared folder, and a repository switches a shared plugin on or off with
7
+ * its own `plugin_record` overlay. Copies made by earlier releases are reported
8
+ * by `plugin copies` so they can be audited and removed.
9
+ */
2
10
  import fs from 'node:fs';
3
11
  import path from 'node:path';
4
- import { readPluginsDir, PACKAGE_ROOT, resolvePluginFile } from './plugin-loader.mjs';
12
+ import { readPluginsDir, globalPluginsDir, PACKAGE_ROOT, resolvePluginFile } from './plugin-loader.mjs';
5
13
  import { DEFAULT_PLUGIN_IDS } from './default-plugins.mjs';
14
+ import { comparePackageVersions } from './package-version.mjs';
6
15
  import { preparePluginDependencies } from './plugin-dependencies.mjs';
7
16
 
8
- export function inspectRequiredPlugins(brainDir) {
9
- const plugins = readPluginsDir(path.join(brainDir, 'plugins'), 'required');
17
+ /**
18
+ * True when this brain is the shared (global) brain that owns the defaults.
19
+ */
20
+ function isSharedBrain(brainDir) {
21
+ return path.resolve(brainDir) === path.resolve(path.dirname(globalPluginsDir()));
22
+ }
23
+
24
+ /**
25
+ * Validate the required capability for a brain. A shared brain validates its own
26
+ * installs; a project brain validates its own copy when it still has one and
27
+ * otherwise the shared folder that discovery already falls back to.
28
+ */
29
+ export function inspectRequiredPlugins(brainDir, { pluginsDir, sharedBrainDir } = {}) {
30
+ const ownDir = path.join(brainDir, 'plugins');
31
+ const sharedPlugins = sharedBrainDir ? path.join(sharedBrainDir, 'plugins') : globalPluginsDir();
32
+ // A project brain's plugins directory can exist yet hold none of the defaults
33
+ // (provisioning creates it empty). The shared folder is what actually serves
34
+ // the repository, so consult it whenever this brain carries no auth of its own.
35
+ const resolved = pluginsDir || (isSharedBrain(brainDir) ? globalPluginsDir()
36
+ : (hasOwnRequired(ownDir) ? ownDir : sharedPlugins));
37
+ const own = readPluginsDir(ownDir, 'required');
38
+ const shared = path.resolve(resolved) === path.resolve(ownDir) ? [] : readPluginsDir(resolved, 'shared');
39
+ const seen = new Set();
40
+ const plugins = [...own, ...shared].filter(plugin => (seen.has(plugin.id) ? false : seen.add(plugin.id)));
10
41
  const errors = [];
11
42
  const auth = plugins.find(plugin => plugin.id === 'auth');
12
43
  if (!auth?.valid) errors.push('Required auth plugin is missing or invalid');
@@ -23,24 +54,131 @@ export function inspectRequiredPlugins(brainDir) {
23
54
  } catch { errors.push(`Required auth file is missing or unsafe: ${file || 'CLI handler'}`); }
24
55
  }
25
56
  }
26
- return { status: errors.length ? 'failed' : 'ready', errors };
57
+ const source = !auth ? null : (path.resolve(auth.pluginsDir) === path.resolve(ownDir) ? 'local' : 'shared');
58
+ return { status: errors.length ? 'failed' : 'ready', errors, source };
59
+ }
60
+
61
+ /** True when this brain carries the required plugin itself rather than by fallback. */
62
+ function hasOwnRequired(ownDir) {
63
+ return fs.existsSync(path.join(ownDir, 'auth', 'plugin.json'));
64
+ }
65
+
66
+ /** Only a copy that declares an older release than the bundled one is refreshed; damaged same-release copies still fail. */
67
+ function isStaleInstall(target, source) {
68
+ try {
69
+ const installed = JSON.parse(fs.readFileSync(path.join(target, 'plugin.json'), 'utf8')).version;
70
+ const bundled = JSON.parse(fs.readFileSync(path.join(source, 'plugin.json'), 'utf8')).version;
71
+ return comparePackageVersions(installed, bundled) === -1;
72
+ } catch { return false; }
27
73
  }
28
74
 
29
- export function ensureRequiredPlugins(brainDir, { sourceDir = path.join(PACKAGE_ROOT, 'scaffold', '.agent', 'skills', 'total-recall', 'plugins') } = {}) {
75
+ /**
76
+ * Ensure the required plugins exist for a brain.
77
+ *
78
+ * There is one shared install per machine, in the global brain. A project brain
79
+ * never receives a copy: it is served by the shared folder, and a repository
80
+ * switches a shared plugin on or off with its own record. Provisioning a project
81
+ * therefore makes sure the SHARED brain is seeded, then validates that the
82
+ * required capability resolves for the project. Copies left in a project by an
83
+ * earlier release are refreshed in place (so they stay usable until removed),
84
+ * never created.
85
+ */
86
+ export function ensureRequiredPlugins(brainDir, { sourceDir = path.join(PACKAGE_ROOT, 'scaffold', '.agent', 'skills', 'total-recall', 'plugins'), sharedBrainDir } = {}) {
30
87
  // Validate the complete package before changing any installation.
31
88
  for (const id of DEFAULT_PLUGIN_IDS) {
32
89
  if (!fs.existsSync(path.join(sourceDir, id, 'plugin.json'))) throw new Error(`Required bundled ${id} plugin is missing`);
33
90
  }
91
+ if (!isSharedBrain(brainDir)) {
92
+ // A repository installs nothing of its own. The shared brain is what serves
93
+ // it, so seed that one — never this project's plugins directory.
94
+ const sharedBrain = sharedBrainDir || path.dirname(globalPluginsDir());
95
+ if (path.resolve(sharedBrain) !== path.resolve(brainDir)) installDefaultsInto(sharedBrain, sourceDir);
96
+ return inspectRequired(brainDir, { sharedBrainDir: sharedBrain });
97
+ }
98
+ installDefaultsInto(brainDir, sourceDir);
99
+ return inspectRequired(brainDir);
100
+ }
101
+
102
+ /** Seed or refresh the default plugins inside one brain. */
103
+ function installDefaultsInto(brainDir, sourceDir) {
34
104
  for (const id of DEFAULT_PLUGIN_IDS) {
35
- const target = path.join(brainDir, 'plugins', id);
36
- if (!fs.existsSync(target)) {
105
+ const target = path.join(brainDir, 'plugins', id);
37
106
  const source = path.join(sourceDir, id);
38
- fs.mkdirSync(path.dirname(target), { recursive: true });
39
- fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true });
40
- }
41
- preparePluginDependencies(target);
107
+ if (!fs.existsSync(target)) {
108
+ fs.mkdirSync(path.dirname(target), { recursive: true });
109
+ fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true });
110
+ } else if (isStaleInstall(target, source)) {
111
+ // A vendored copy that no longer validates (or predates the bundled release) is replaced; node_modules stays.
112
+ for (const entry of fs.readdirSync(target)) if (entry !== 'node_modules') fs.rmSync(path.join(target, entry), { recursive: true, force: true });
113
+ for (const entry of fs.readdirSync(source)) if (entry !== 'node_modules') fs.cpSync(path.join(source, entry), path.join(target, entry), { recursive: true });
114
+ }
115
+ preparePluginDependencies(target);
42
116
  }
43
- const report = inspectRequiredPlugins(brainDir);
117
+ }
118
+
119
+ /** Validate and fail loudly; provisioning must not report a brain it cannot serve. */
120
+ function inspectRequired(brainDir, options = {}) {
121
+ const report = inspectRequiredPlugins(brainDir, options);
44
122
  if (report.status !== 'ready') throw new Error(report.errors.join('; '));
45
123
  return report;
46
124
  }
125
+
126
+ /**
127
+ * Audit per-repository copies of the shared defaults.
128
+ *
129
+ * A copy is compared with the bundled plugin source this package ships, because
130
+ * that is what an install was made from. The comparison ignores installed
131
+ * dependencies, test scaffolding and a shared install's own generated state, so
132
+ * a copy reports `edited` only when its plugin source differs — meaning somebody
133
+ * changed the plugin inside that repository, and it must not be removed blindly.
134
+ */
135
+ export function auditProjectPluginCopies(projectRoot, { sharedDir = globalPluginsDir(), sourceDir = path.join(PACKAGE_ROOT, 'scaffold', '.agent', 'skills', 'total-recall', 'plugins') } = {}) {
136
+ const pluginsDir = path.join(projectRoot, '.agent', 'skills', 'total-recall', 'plugins');
137
+ if (!fs.existsSync(pluginsDir)) return { copies: [], count: 0, sourceDir };
138
+ const copies = [];
139
+ for (const id of DEFAULT_PLUGIN_IDS) {
140
+ const copyDir = path.join(pluginsDir, id);
141
+ if (!fs.existsSync(copyDir)) continue;
142
+ const source = path.join(sourceDir, id);
143
+ const shared = path.join(sharedDir, id);
144
+ const row = {
145
+ id, dir: copyDir,
146
+ sourceDir: fs.existsSync(source) ? source : null,
147
+ sharedDir: fs.existsSync(shared) ? shared : null,
148
+ edited: null, version: null, sourceVersion: null, sharedVersion: null
149
+ };
150
+ const version = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8')).version; } catch { return null; } };
151
+ row.version = version(copyDir);
152
+ row.sourceVersion = row.sourceDir ? version(row.sourceDir) : null;
153
+ row.sharedVersion = row.sharedDir ? version(row.sharedDir) : null;
154
+ row.edited = row.sourceDir ? !sameTree(copyDir, row.sourceDir) : null;
155
+ copies.push(row);
156
+ }
157
+ return { copies, count: copies.length, sourceDir };
158
+ }
159
+
160
+ /** Compare two plugin trees, ignoring installed dependencies and generated state. */
161
+ function sameTree(left, right) {
162
+ const ignore = new Set(['node_modules', '.git', 'test', 'vitest.config.mjs', 'package-lock.json']);
163
+ // A shared install runs in place, so its tree also carries compiled surfaces
164
+ // and a local secrets projection. Those are not plugin source.
165
+ const ignored = (name) => ignore.has(name) || name.startsWith('.env');
166
+ const walk = (dir, base = '') => {
167
+ const out = [];
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
169
+ if (ignored(entry.name)) continue;
170
+ const relative = base ? `${base}/${entry.name}` : entry.name;
171
+ const full = path.join(dir, entry.name);
172
+ // Symlinks are compared by their target text; a linked dev checkout is not
173
+ // "edited", it is owned elsewhere.
174
+ if (entry.isSymbolicLink()) { out.push([relative, `link:${fs.readlinkSync(full)}`]); continue; }
175
+ if (entry.isDirectory()) { out.push(...walk(full, relative)); continue; }
176
+ out.push([relative, fs.readFileSync(full, 'utf8')]);
177
+ }
178
+ return out;
179
+ };
180
+ try {
181
+ const a = walk(left); const b = walk(right);
182
+ return JSON.stringify(a) === JSON.stringify(b);
183
+ } catch { return false; }
184
+ }
@@ -93,7 +93,7 @@ export function skillPackageFiles(skillDir) {
93
93
  const files = [];
94
94
  const visit = (dir, relative = '') => {
95
95
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
96
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.DS_Store') continue;
96
+ if (skillPackageExcluded(entry.name)) continue;
97
97
  const next = relative ? `${relative}/${entry.name}` : entry.name;
98
98
  if (entry.isDirectory()) visit(path.join(dir, entry.name), next);
99
99
  else if (entry.isFile() || entry.isSymbolicLink()) files.push(next);
@@ -264,14 +264,38 @@ export function listInstalls(brainDir, { skillId = null, repo = null } = {}) {
264
264
  }
265
265
 
266
266
  /**
267
- * Recursive copy of skill directory (skip node_modules, .git).
267
+ * State that is never part of a skill package.
268
+ *
269
+ * A skill's package is its content: SKILL.md, references, prompts, scripts,
270
+ * subagents, evals and assets. Everything listed here is either the agent's
271
+ * private state, disposable derived data, or a nested install belonging to
272
+ * something else — and `config/secrets.enc` is the credential store itself,
273
+ * which must never be copied into another repository or published. Excluding
274
+ * them also stops the package hash changing every time memory is written.
275
+ */
276
+ const SKILL_PACKAGE_EXCLUDED_DIRS = new Set([
277
+ 'node_modules', '.git', 'memory-vault', 'memory-derived', 'derived',
278
+ 'sessions', 'logs', 'browser-profile', 'plugins', 'plugin-state',
279
+ 'memory-inbox', 'dist', 'coverage', '.cache', 'tmp',
280
+ ]);
281
+ const SKILL_PACKAGE_EXCLUDED_FILES = [
282
+ /^\.DS_Store$/, /^secrets\.enc(\.|$)/, /^\.env(\.|$)/, /^id_[a-z]+$/, /\.pem$/,
283
+ ];
284
+
285
+ /** True when a directory entry is private, derived or a nested install. */
286
+ export function skillPackageExcluded(name) {
287
+ return SKILL_PACKAGE_EXCLUDED_DIRS.has(name) || SKILL_PACKAGE_EXCLUDED_FILES.some((re) => re.test(name));
288
+ }
289
+
290
+ /**
291
+ * Recursive copy of skill directory, content only.
268
292
  */
269
293
  export function copySkillDir(src, dest) {
270
294
  if (!fs.existsSync(src)) throw new Error(`Source missing: ${src}`);
271
295
  fs.mkdirSync(dest, { recursive: true });
272
296
  const entries = fs.readdirSync(src, { withFileTypes: true });
273
297
  for (const ent of entries) {
274
- if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === '.DS_Store') continue;
298
+ if (skillPackageExcluded(ent.name)) continue;
275
299
  const from = path.join(src, ent.name);
276
300
  const to = path.join(dest, ent.name);
277
301
  if (ent.isDirectory()) {
@@ -20,7 +20,6 @@ import {
20
20
  resolvePrimitiveDefinition,
21
21
  } from '@ssss/cli/registry';
22
22
  import { detectDirectWrites } from '@ssss/cli/guard';
23
- import { logger } from './logger.mjs';
24
23
  import { validateMemoryNode } from './total-recall-memory-validator.mjs';
25
24
  import {
26
25
  TOTAL_RECALL_HOST_EXTENSION,
@@ -56,6 +55,7 @@ const DIRECT_WRITE_APPROVED = [
56
55
  'src/core/ssss-kernel-bridge.mjs',
57
56
  'src/core/validated-write.mjs',
58
57
  'src/core/vault.mjs',
58
+ 'src/core/vault-files.mjs',
59
59
  'src/core/surface.mjs',
60
60
  'src/core/blackboard.mjs',
61
61
  'src/core/steering.mjs',
@@ -450,6 +450,8 @@ export function compareVerdicts(localResult, kernelResult) {
450
450
  }
451
451
 
452
452
  export async function shadowCompare(envelope, vaultRoot, localResult, options = {}) {
453
+ // Ordinary kernel mutations must not bootstrap configuration/auth/plugins.
454
+ const { logger } = await import('./logger.mjs');
453
455
  const dryEnvelope = { ...envelope, dry_run: true };
454
456
  try {
455
457
  const kernelResult = await processViaPackageKernel(dryEnvelope, vaultRoot, options);
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { spawn } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { readProcessCommand } from './pid-lock.mjs';
8
8
  import { CLI_COMMANDS } from './cli-commands.mjs';
@@ -12,8 +12,8 @@ export const cliEntry = path.join(packageRoot, 'bin/total-recall.mjs');
12
12
 
13
13
  export function runBounded(binary, args, { cwd = process.cwd(), timeoutMs = 15000, maxBytes = 262144 } = {}) {
14
14
  return new Promise(resolve => {
15
- let child, output = '', size = 0, reason = null, settled = false, timer, escalation;
16
- const finish = code => { if (settled) return; settled = true; clearTimeout(timer); resolve({ code, output, reason }); };
15
+ let child, output = '', stderr = '', size = 0, reason = null, settled = false, timer, escalation;
16
+ const finish = code => { if (settled) return; settled = true; clearTimeout(timer); resolve({ code, output, stderr: stderr.slice(-2000), reason }); };
17
17
  const kill = signal => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal); else child.kill(signal); } catch {} };
18
18
  const stop = why => { if (reason || settled) return; reason = why; kill('SIGTERM'); escalation = setTimeout(() => { kill('SIGKILL'); finish(null); }, 500); };
19
19
  try { child = spawn(binary, args, { cwd, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); }
@@ -21,7 +21,7 @@ export function runBounded(binary, args, { cwd = process.cwd(), timeoutMs = 1500
21
21
  timer = setTimeout(() => stop('timeout'), timeoutMs);
22
22
  child.on('error', () => { reason = 'spawn-failed'; clearTimeout(escalation); finish(null); });
23
23
  child.stdout.on('data', chunk => { size += chunk.length; if (size <= maxBytes) output += chunk.toString(); else stop('output-limit'); });
24
- child.stderr.on('data', chunk => { size += chunk.length; if (size > maxBytes) stop('output-limit'); });
24
+ child.stderr.on('data', chunk => { size += chunk.length; if (size <= maxBytes) stderr += chunk.toString(); else stop('output-limit'); });
25
25
  child.on('close', code => { if (!reason) finish(code); });
26
26
  });
27
27
  }
@@ -33,16 +33,57 @@ export async function verifyListener(pid, url, { run = runBounded, cwd = process
33
33
  return result.code === 0 && !result.reason && result.output.split(/\r?\n/).includes(`p${pid}`);
34
34
  }
35
35
 
36
- export function processIdentity(pidFile, hint, { alive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }, command = readProcessCommand } = {}) {
36
+ /** A process's working directory, or null when it cannot be determined. */
37
+ export function readProcessCwd(pid, { run = spawnSync } = {}) {
38
+ try {
39
+ const res = run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 5_000 });
40
+ if (res.status !== 0 || !res.stdout) return null;
41
+ const line = res.stdout.split(/\r?\n/).find(l => l.startsWith('n'));
42
+ return line ? line.slice(1) || null : null;
43
+ } catch { return null; }
44
+ }
45
+
46
+ /**
47
+ * Does the process holding `pid` look like the entry at `hint`?
48
+ *
49
+ * A launch agent starts its program with a path relative to the service's
50
+ * WorkingDirectory (`node src/server/index.mjs`), so the recorded command line
51
+ * carries no absolute path. Resolving it against the *caller's* cwd made this
52
+ * check depend on where the operator happened to stand: `total-recall startup
53
+ * check` run from any other directory reported a healthy local server as
54
+ * `server-process-unverified`. Resolve against the process's own cwd instead,
55
+ * An unresolved entry is unknown; a matching suffix alone cannot identify an
56
+ * installation and must never authorize lifecycle actions.
57
+ */
58
+ export function processIdentity(pidFile, hint, { alive = pid => { try { process.kill(pid, 0); return true; } catch (err) { if (err.code === 'ESRCH') return false; throw err; } }, command = readProcessCommand, cwd = readProcessCwd } = {}) {
37
59
  let pid;
38
- try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); } catch { return { status: 'not-started' }; }
39
- if (!Number.isInteger(pid) || pid <= 0 || !alive(pid)) return { status: 'stopped' };
40
- const cmd = command(pid);
41
- if (!cmd) return { status: 'unknown', reason: 'process-identity-unavailable' };
60
+ try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); }
61
+ catch (err) { return err.code === 'ENOENT' ? { status: 'not-started' } : { status: 'unknown', reason: 'pid-unreadable' }; }
62
+ if (!Number.isInteger(pid) || pid <= 0) return { status: 'unknown', reason: 'invalid-pid' };
63
+ try { if (!alive(pid)) return { status: 'stopped' }; }
64
+ catch { return { status: 'unknown', reason: 'process-liveness-unavailable', pid }; }
65
+ let cmd;
66
+ try { cmd = command(pid); } catch { /* unknown below */ }
67
+ if (!cmd) return { status: 'unknown', reason: 'process-identity-unavailable', pid };
42
68
  const script = cmd.trim().match(/^(?:\S*node(?:\s+--[^ ]+)*)\s+(\S+)/)?.[1];
43
- let matches = false;
44
- try { matches = Boolean(script) && fs.realpathSync(script) === fs.realpathSync(hint); } catch {}
45
- return matches ? { status: 'running', pid } : { status: 'conflict', reason: 'foreign-pid' };
69
+ let entry = null;
70
+ if (script) {
71
+ // Absolute script paths resolve directly; relative ones resolve against the
72
+ // process's own working directory, never the caller's.
73
+ try {
74
+ const base = path.isAbsolute(script) ? null : cwd(pid);
75
+ if (!path.isAbsolute(script) && !base) throw new Error('Missing process cwd');
76
+ entry = fs.realpathSync(base ? path.resolve(base, script) : script);
77
+ if (entry === fs.realpathSync(hint)) return { status: 'running', pid, entry };
78
+ } catch { return { status: 'unknown', reason: 'process-entry-unavailable', pid }; }
79
+ }
80
+ return { status: 'conflict', reason: 'foreign-pid', pid, ...(entry ? { entry } : {}) };
81
+ }
82
+
83
+ /** The single installation-aware daemon observation for status and lifecycle. */
84
+ export function observeDaemon(brainDir, { identity = processIdentity } = {}) {
85
+ const expected_entry = path.join(packageRoot, 'src', 'core', 'daemon-loop.mjs');
86
+ return { ...identity(path.join(brainDir, 'daemon.pid'), expected_entry), expected_entry, source: 'local-pid' };
46
87
  }
47
88
 
48
89
  export function healthVerdict(body) {
@@ -81,7 +122,6 @@ export async function fetchStartup(url, options = {}, fetchImpl = fetch) {
81
122
  export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl = null, token = null, brainId = null, ensure = false,
82
123
  appCheck = null, appStart = null, run = runBounded, fetchImpl = fetch, listener = verifyListener, platform = process.platform, home = os.homedir(), identity = processIdentity } = {}) {
83
124
  const actions = [];
84
- const daemonFile = path.join(brainDir, 'daemon.pid');
85
125
  const serverFile = path.join(brainDir, 'server.pid');
86
126
  const report = { checked_at: new Date().toISOString(), server: { status: 'not-configured' }, brain: { status: 'unknown' }, daemon: { status: 'unknown' },
87
127
  ssss: { status: 'unknown', role: 'document-engine-tooling; no standalone daemon required' }, app: { status: 'not-declared' }, actions };
@@ -105,7 +145,7 @@ export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl =
105
145
  } catch { return { status: 'offline', reason: 'health-unreachable' }; }
106
146
  };
107
147
  report.server = await probeServer();
108
- report.daemon = local ? { ...identity(daemonFile, path.join(packageRoot, 'src', 'core', 'daemon-loop.mjs')), source: 'local-pid' } : remoteDaemon;
148
+ report.daemon = local ? observeDaemon(brainDir, { identity }) : remoteDaemon;
109
149
  const serverIdentity = local ? identity(serverFile, path.join(packageRoot, 'src', 'server', 'index.mjs')) : null;
110
150
  if (ensure && local && report.server.status === 'offline' && ['not-started', 'stopped'].includes(serverIdentity.status)) {
111
151
  const plist = path.join(home, 'Library', 'LaunchAgents', 'com.totalrecall.server.plist');
@@ -126,7 +166,7 @@ export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl =
126
166
  const result = await run(process.execPath, [cliEntry, 'daemon', 'start'], { cwd });
127
167
  actions.push({ component: 'daemon', action: 'cli-start', accepted: result.code === 0 && !result.reason });
128
168
  for (let i = 0; i < 10; i++) {
129
- report.daemon = { ...identity(daemonFile, path.join(packageRoot, 'src', 'core', 'daemon-loop.mjs')), source: 'local-pid' }; if (report.daemon.status === 'running') break;
169
+ report.daemon = observeDaemon(brainDir, { identity }); if (report.daemon.status === 'running') break;
130
170
  await new Promise(resolve => setTimeout(resolve, 200));
131
171
  }
132
172
  }
@@ -146,7 +186,17 @@ export async function startupHealth({ cwd = process.cwd(), brainDir, brainUrl =
146
186
  }
147
187
  } catch { report.brain = { status: 'offline', reason: 'instructions-unreachable' }; }
148
188
  }
149
- const candidates = [path.join(cwd, 'ssss'), path.join(cwd, 'node_modules', '.bin', 'ssss')];
189
+ // SSSS is tooling that may be vendored by the project under test, installed
190
+ // alongside this package, or only on PATH. Searching `cwd` alone reported a
191
+ // working install as `unavailable` whenever the operator ran the check from
192
+ // anywhere other than a repository root, which made `shared_ready` depend on
193
+ // the caller's directory rather than on the machine.
194
+ const candidates = [
195
+ path.join(cwd, 'ssss'),
196
+ path.join(cwd, 'node_modules', '.bin', 'ssss'),
197
+ path.join(packageRoot, 'node_modules', '.bin', 'ssss'),
198
+ path.join(packageRoot, '..', '.bin', 'ssss'),
199
+ ];
150
200
  const tool = candidates.find(file => { try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; } }) || 'ssss';
151
201
  const tooling = await run(tool, ['--help'], { cwd });
152
202
  report.ssss = { status: tooling.code === 0 && !tooling.reason && /ssss/i.test(tooling.output) ? 'available' : 'unavailable', role: report.ssss.role };
@@ -35,9 +35,10 @@ export function isSurfaceCodeStale(file = SURFACE_SOURCE, loadedHash = LOADED_SU
35
35
  try { return sourceHash(file) !== loadedHash; } catch { return false; }
36
36
  }
37
37
 
38
- /** Scope dirs searched for composable commands, in dispatcher order (project, then global). */
38
+ /** Composed scope order: selected project, exact-member groups, global. */
39
39
  export function commandDirsFor(skillsDir) {
40
- const dirs = skillsDir ? commandScopeDirectories(path.dirname(path.dirname(skillsDir)), globalAgentDir) : [{ scope: 'global', dir: path.join(globalAgentDir, 'commands') }];
40
+ const dirs = skillsDir ? commandScopeDirectories(path.dirname(path.dirname(skillsDir)), globalAgentDir,
41
+ { projectAgentDir: path.dirname(skillsDir) }) : [{ scope: 'global', dir: path.join(globalAgentDir, 'commands') }];
41
42
  // Compiling the global brain itself: its commands are the global ones.
42
43
  if (dirs.length === 2 && path.resolve(dirs[0].dir) === path.resolve(dirs[1].dir)) dirs.shift();
43
44
  return dirs;
@@ -399,7 +400,7 @@ export function buildInstalledPluginsSummary(projectRoot = process.cwd()) {
399
400
  const desc = (p.manifest.description || 'Plugin capability').replace(/\s+/g, ' ').slice(0, 30).trimEnd();
400
401
  const ops = (p.manifest.cli?.subcommands || []).map(sub => sub.name).filter(Boolean);
401
402
  const shown = ops.join(' | ');
402
- lines.push(`- **${p.id}** v${p.manifest.version || 'unknown'} · ${pluginActivation(p).state} · \`total-recall ${cmd}\` ${shown ? `[${shown}]` : '(no operations)'}: ${desc}`);
403
+ lines.push(`- **${p.id}** v${p.manifest.version || 'unknown'} · ${pluginActivation(p, { projectRoot }).state} · \`total-recall ${cmd}\` ${shown ? `[${shown}]` : '(no operations)'}: ${desc}`);
403
404
  }
404
405
  return lines.join('\n') + '\n';
405
406
  } catch {
@@ -509,7 +510,7 @@ async function writeShim(shimPath, skillsDir, nodes = [], { vaultDir, derivedDir
509
510
  if (!fs.statSync(target).isFile()) throw new Error('Linked instruction target must be a regular file');
510
511
  // Preserve both the link and authored target text; replace only our block.
511
512
  const raw = fs.readFileSync(target, 'utf8');
512
- atomicWrite(target, injectDirectives(raw, rulesBlock));
513
+ writeVerifiedSurface(target, injectDirectives(raw, rulesBlock));
513
514
  return true;
514
515
  } else {
515
516
  const raw = fs.readFileSync(shimPath, 'utf8');
@@ -520,14 +521,14 @@ async function writeShim(shimPath, skillsDir, nodes = [], { vaultDir, derivedDir
520
521
  }
521
522
 
522
523
  const updated = injectDirectives(cleaned, rulesBlock);
523
- atomicWrite(shimPath, updated);
524
+ writeVerifiedSurface(shimPath, updated);
524
525
  return true;
525
526
  }
526
527
  } else {
527
528
  if (!fs.existsSync(shimDir)) {
528
529
  fs.mkdirSync(shimDir, { recursive: true });
529
530
  }
530
- atomicWrite(shimPath, fullContent);
531
+ writeVerifiedSurface(shimPath, fullContent);
531
532
  return true;
532
533
  }
533
534
  } catch (err) {
@@ -535,6 +536,11 @@ async function writeShim(shimPath, skillsDir, nodes = [], { vaultDir, derivedDir
535
536
  }
536
537
  }
537
538
 
539
+ function writeVerifiedSurface(file, content) {
540
+ atomicWrite(file, content);
541
+ if (fs.readFileSync(file, 'utf8') !== content) throw new Error('Instruction read-back verification failed');
542
+ }
543
+
538
544
  /**
539
545
  * Map of client names → shim file paths they require.
540
546
  */
@@ -591,15 +597,39 @@ function readConnectedClients(clientsPath) {
591
597
  }
592
598
 
593
599
  return null;
594
- } catch {
595
- return null;
600
+ } catch (err) {
601
+ throw new Error(`Cannot read connected instruction clients: ${err.code || err.message}`);
596
602
  }
597
603
  }
598
604
 
605
+ function instructionFiles(instructionsFile, skillsDir) {
606
+ const base = path.dirname(instructionsFile);
607
+ const agent = skillsDir ? path.dirname(skillsDir) : path.join(base, '.agent');
608
+ const clients = readConnectedClients(path.join(agent, 'skills', 'total-recall', 'config', 'clients.json'));
609
+ return [...new Set([instructionsFile, ...[...(clients || [])].flatMap(client =>
610
+ (CLIENT_SHIMS[client] || []).map(file => path.join(base, file)))])];
611
+ }
612
+
613
+ function describeInstruction(file, base) {
614
+ return { file: path.relative(base, file), target: path.relative(base, fs.realpathSync(file)), sha256: sourceHash(file) };
615
+ }
616
+
617
+ /** Derived hashes certify exact output bytes and link bindings, never runtime consumption. */
618
+ function instructionArtifactsMatch(files, base, manifestFile) {
619
+ try {
620
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
621
+ if (manifest.version !== 1 || !Array.isArray(manifest.files) || manifest.files.length !== files.length) return false;
622
+ return files.every((file, index) => {
623
+ const current = describeInstruction(file, base), previous = manifest.files[index];
624
+ return previous?.file === current.file && previous.target === current.target && previous.sha256 === current.sha256;
625
+ });
626
+ } catch { return false; }
627
+ }
628
+
599
629
  /**
600
630
  * Compile shims asynchronously.
601
631
  */
602
- async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir, derivedDir, force = false } = {}) {
632
+ async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir, derivedDir, force = false, files = instructionFiles(instructionsFile, skillsDir) } = {}) {
603
633
  const agentDir = path.dirname(instructionsFile);
604
634
  // `instructionsFile` is always `<agentDir>/INSTRUCTIONS.md`, so agentDir is
605
635
  // already the directory the surface belongs in. Do not derive it again.
@@ -616,12 +646,6 @@ async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultD
616
646
  // (rebuild.mjs: `<agentDir>/INSTRUCTIONS.md` with skillsDir `<agentDir>/skills`;
617
647
  // plugin-store.mjs likewise), so the peel was never correct for either.
618
648
  const baseDir = agentDir;
619
- let injectedCount = 0;
620
-
621
- // Always write the canonical INSTRUCTIONS.md
622
- if (await writeShim(path.join(baseDir, 'INSTRUCTIONS.md'), skillsDir, nodes, { vaultDir, derivedDir, force })) {
623
- injectedCount++;
624
- }
625
649
 
626
650
  // Determine which client shims to write.
627
651
  // `baseDir/.agent/skills/...` only holds for a PROJECT brain. For the global
@@ -631,9 +655,6 @@ async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultD
631
655
  // stopped updating. Derive the brain dir from the caller's skillsDir instead,
632
656
  // which is correct for both layers. Fall back to the legacy guess only when
633
657
  // skillsDir was not supplied.
634
- const brainDirForConfig = skillsDir ? path.dirname(skillsDir) : path.join(baseDir, '.agent');
635
- const clientsPath = path.join(brainDirForConfig, 'skills', 'total-recall', 'config', 'clients.json');
636
- const connectedClients = readConnectedClients(clientsPath);
637
658
 
638
659
  // Client shims are written BESIDE INSTRUCTIONS.md, in the same directory, so
639
660
  // the surface directory is decided in exactly one place: `instructionsFile`.
@@ -645,20 +666,9 @@ async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultD
645
666
  // `instructionsFile`; this function must not re-derive or relocate it. Doing
646
667
  // so previously wrote every project shim into `<repo>/.agent/AGENTS.md`, where
647
668
  // no harness looks, while still reporting a successful compilation.
648
- if (connectedClients !== null) {
649
- // Only write shims for connected clients
650
- for (const client of connectedClients) {
651
- const files = CLIENT_SHIMS[client];
652
- if (!files) continue;
653
- for (const file of files) {
654
- if (await writeShim(path.join(baseDir, file), skillsDir, nodes, { vaultDir, derivedDir, force })) {
655
- injectedCount++;
656
- }
657
- }
658
- }
659
- }
669
+ for (const file of files) await writeShim(file, skillsDir, nodes, { vaultDir, derivedDir, force });
660
670
 
661
- return injectedCount;
671
+ return files.length;
662
672
  }
663
673
 
664
674
  /**
@@ -709,13 +719,17 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
709
719
  // Commands and skills feed the surface too; a change to either must recompile
710
720
  // even when no memory node changed.
711
721
  const inputsHashFile = path.join(derivedDir, 'surface-inputs-hash.txt');
712
- const inputsHash = sourceHash(SURFACE_SOURCE) + catalog.fingerprint + surfaceInputsHash({ commandDirs: commandDirsFor(skillsDir), skillsDir });
722
+ const files = instructionFiles(instructionsFile, skillsDir);
723
+ const surfaceDir = path.dirname(instructionsFile);
724
+ const artifactsFile = path.join(derivedDir, 'surface-artifacts.json');
725
+ const inputsHash = sourceHash(SURFACE_SOURCE) + catalog.fingerprint + JSON.stringify(files) + surfaceInputsHash({ commandDirs: commandDirsFor(skillsDir), skillsDir });
713
726
 
714
727
  if (!force && fs.existsSync(hashFile)) {
715
728
  const storedHash = fs.readFileSync(hashFile, 'utf8').trim();
716
729
  const storedGlobal = fs.existsSync(globalHashFile) ? fs.readFileSync(globalHashFile, 'utf8').trim() : '';
717
730
  const storedInputs = fs.existsSync(inputsHashFile) ? fs.readFileSync(inputsHashFile, 'utf8').trim() : '';
718
- if (storedHash === currentHash && storedGlobal === globalHash && storedInputs === inputsHash) {
731
+ if (storedHash === currentHash && storedGlobal === globalHash && storedInputs === inputsHash &&
732
+ instructionArtifactsMatch(files, surfaceDir, artifactsFile)) {
719
733
  return {
720
734
  nodesProcessed: nodes.length,
721
735
  skillsInjected: 0,
@@ -728,7 +742,7 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
728
742
  }
729
743
 
730
744
  // 1. Write pointer and active rules to all instruction shims
731
- const skillsInjected = await compilePointers(instructionsFile, skillsDir, ruleNodes, { vaultDir, derivedDir, force });
745
+ const skillsInjected = await compilePointers(instructionsFile, skillsDir, ruleNodes, { vaultDir, derivedDir, force, files });
732
746
 
733
747
  // 2. Build derived indexes (powers semantic search API)
734
748
  if (!fs.existsSync(derivedDir)) {
@@ -820,17 +834,9 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
820
834
  atomicWrite(hashFile, currentHash);
821
835
  atomicWrite(globalHashFile, globalHash);
822
836
  atomicWrite(inputsHashFile, inputsHash);
837
+ atomicWrite(artifactsFile, JSON.stringify({ version: 1, files: files.map(file => describeInstruction(file, surfaceDir)) }, null, 2));
823
838
  writeProjectionManifest(derivedDir, currentHash);
824
839
 
825
- // 6. Generate live OKF Index and Log
826
- try {
827
- const { generateLiveIndex, generateLiveLog } = await import('./okf-adapter.mjs');
828
- generateLiveIndex(vaultDir);
829
- generateLiveLog(vaultDir);
830
- } catch (err) {
831
- logger.warn('surface', `Failed to generate live OKF index/log: ${err.message}`);
832
- }
833
-
834
840
  return {
835
841
  nodesProcessed: nodes.length,
836
842
  skillsInjected,
@@ -875,7 +881,8 @@ function writeProjectionManifest(derivedDir, vaultHash) {
875
881
  { file: 'local-search.json', disposable: true },
876
882
  { file: 'embeddings.jsonl', disposable: true },
877
883
  { file: 'cli-semantic-index.json', disposable: true },
878
- { file: 'vault-hash.txt', disposable: true }
884
+ { file: 'vault-hash.txt', disposable: true },
885
+ { file: 'surface-artifacts.json', disposable: true }
879
886
  ],
880
887
  rebuild_command: 'npx total-recall compile'
881
888
  };