total-recall-brain 3.31.2 → 3.32.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 (34) hide show
  1. package/bin/total-recall.mjs +2 -2
  2. package/package.json +2 -2
  3. package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
  4. package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
  5. package/src/cli/app/index.mjs +181 -1
  6. package/src/cli/command.mjs +269 -5
  7. package/src/cli/connect.mjs +1 -1
  8. package/src/cli/daemon.mjs +3 -3
  9. package/src/cli/init.mjs +54 -0
  10. package/src/cli/plugin/create.mjs +20 -10
  11. package/src/cli/repo-expert-generate.mjs +37 -3
  12. package/src/cli/skill-config.mjs +211 -0
  13. package/src/cli/skill.mjs +37 -2
  14. package/src/core/app-deploy/adapter-contract.mjs +176 -0
  15. package/src/core/app-deploy/design-tokens.mjs +148 -108
  16. package/src/core/app-deploy/plan.mjs +2 -2
  17. package/src/core/app-deploy/resolve.mjs +30 -2
  18. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
  19. package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
  20. package/src/core/app-deploy/runtime-cli.mjs +254 -0
  21. package/src/core/app-deploy/ui-elements.mjs +473 -0
  22. package/src/core/app-deploy/verify.mjs +18 -0
  23. package/src/core/command-surface.mjs +129 -0
  24. package/src/core/daemon-control.mjs +31 -12
  25. package/src/core/daemon-loop.mjs +14 -7
  26. package/src/core/plugin-loader.mjs +12 -12
  27. package/src/core/schema.mjs +72 -13
  28. package/src/core/skill-config.mjs +376 -0
  29. package/src/core/skills-registry.mjs +147 -31
  30. package/src/core/surface.mjs +52 -7
  31. package/src/server/routes/system.mjs +1 -1
  32. package/plugins/code-quality/README.md +0 -25
  33. package/plugins/code-quality/cli.mjs +0 -25
  34. package/plugins/code-quality/plugin.json +0 -17
@@ -12,6 +12,7 @@ import os from 'os';
12
12
  import YAML from 'yaml';
13
13
  import matter from 'gray-matter';
14
14
  import { ensureFullProjectBrain } from './project-brain.mjs';
15
+ import { checkSkillLayerContract } from './skill-config.mjs';
15
16
 
16
17
  export const REGISTRY_VERSION = 1;
17
18
 
@@ -96,6 +97,41 @@ export function hashSkillContent(skillDir) {
96
97
  return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16);
97
98
  }
98
99
 
100
+ /** Hash a skill layer without treating repo-owned edits as core drift. */
101
+ export function hashSkillLayer(skillDir, layer) {
102
+ const base = layer === 'core' ? path.join(skillDir, 'core') : skillDir;
103
+ if (!fs.existsSync(base)) return null;
104
+ if (fs.lstatSync(base).isSymbolicLink()) throw new Error(`Skill layer cannot be a symlink: ${base}`);
105
+ const hash = crypto.createHash('sha256');
106
+ let count = 0;
107
+ const visit = (dir, relative = '') => {
108
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
109
+ if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.DS_Store') continue;
110
+ if (layer === 'repo' && !relative && entry.name === 'core') continue;
111
+ const next = relative ? `${relative}/${entry.name}` : entry.name;
112
+ const file = path.join(dir, entry.name);
113
+ if (entry.isDirectory()) visit(file, next);
114
+ else if (entry.isFile()) {
115
+ hash.update(next).update('\0').update(fs.readFileSync(file)).update('\0');
116
+ count++;
117
+ } else if (entry.isSymbolicLink()) {
118
+ if (layer === 'core') throw new Error(`Skill core cannot contain a symlink: ${next}`);
119
+ hash.update(next).update('\0').update(fs.readlinkSync(file)).update('\0');
120
+ count++;
121
+ }
122
+ }
123
+ };
124
+ visit(base);
125
+ return count ? hash.digest('hex').slice(0, 16) : null;
126
+ }
127
+
128
+ function isLayeredSkill(skillDir) {
129
+ const core = path.join(skillDir, 'core');
130
+ if (!fs.existsSync(core)) return false;
131
+ if (fs.lstatSync(core).isSymbolicLink()) throw new Error(`Skill core cannot be a symlink: ${core}`);
132
+ return fs.lstatSync(core).isDirectory();
133
+ }
134
+
99
135
  /**
100
136
  * Read skill metadata from a local skill directory.
101
137
  */
@@ -110,6 +146,8 @@ export function readSkillMeta(skillDir) {
110
146
  data.name ||
111
147
  data.slug ||
112
148
  path.basename(path.resolve(skillDir));
149
+ const coreHash = isLayeredSkill(skillDir) ? hashSkillLayer(skillDir, 'core') : null;
150
+ if (isLayeredSkill(skillDir) && !coreHash) throw new Error(`Skill core is empty: ${skillDir}`);
113
151
  return {
114
152
  id: String(id).replace(/[^a-zA-Z0-9._-]/g, '-').toLowerCase(),
115
153
  title: data.title || data.name || id,
@@ -118,6 +156,7 @@ export function readSkillMeta(skillDir) {
118
156
  tags: Array.isArray(data.tags) ? data.tags : [],
119
157
  repo_scoped: Boolean(data.repo_scoped),
120
158
  content_hash: hashSkillContent(skillDir),
159
+ core_hash: coreHash,
121
160
  source_path: path.resolve(skillDir),
122
161
  };
123
162
  }
@@ -154,6 +193,8 @@ export function registerSkill(brainDir, skillPath, opts = {}) {
154
193
  source_type: opts.source_type || prev.source_type || 'local',
155
194
  source_path: abs,
156
195
  content_hash: meta.content_hash,
196
+ core_hash: meta.core_hash,
197
+ layered: Boolean(meta.core_hash),
157
198
  registered_at: prev.registered_at || new Date().toISOString(),
158
199
  updated_at: new Date().toISOString(),
159
200
  };
@@ -326,6 +367,7 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
326
367
  const resolved = resolveSkillSource(brainDir, skillIdOrPath, opts.agentSkillsDir);
327
368
  const destDir = path.join(destSkills, resolved.id);
328
369
  const sourceMeta = readSkillMeta(resolved.sourcePath);
370
+ const layered = Boolean(sourceMeta.core_hash);
329
371
  const sourceRepo = repoForSkillPath(brainDir, resolved.sourcePath);
330
372
 
331
373
  if (
@@ -341,7 +383,7 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
341
383
  if (
342
384
  sourceRepo &&
343
385
  canonicalPath(sourceRepo) !== canonicalPath(repoRoot) &&
344
- !opts.allowRepoScopedCrossRepo
386
+ !opts.allowRepoScopedCrossRepo && !layered
345
387
  ) {
346
388
  throw new Error(
347
389
  `Refusing to deploy repo-owned catalog skill "${resolved.id}" from ${sourceRepo} into ${repoRoot}. ` +
@@ -354,10 +396,19 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
354
396
  // an explicit CLI signal for callers that require replacement semantics.
355
397
  }
356
398
 
357
- const replacement = replaceSkillDir(resolved.sourcePath, destDir);
399
+ const existed = fs.existsSync(path.join(destDir, 'SKILL.md'));
400
+ if (layered && fs.existsSync(destDir) && fs.lstatSync(destDir).isSymbolicLink()) {
401
+ throw new Error(`Refusing layered deploy into symlink: ${destDir}`);
402
+ }
403
+ const replacement = layered && existed
404
+ ? replaceSkillDir(path.join(resolved.sourcePath, 'core'), path.join(destDir, 'core'), {
405
+ requiredFile: null,
406
+ preserveExisting: false,
407
+ })
408
+ : replaceSkillDir(resolved.sourcePath, destDir);
358
409
 
359
410
  let adaptResult = { adapted: false };
360
- if (opts.adapt) {
411
+ if (opts.adapt && (!layered || !existed)) {
361
412
  const openwikiDir =
362
413
  opts.openwikiDir ||
363
414
  path.join(repoRoot, '.agent', 'skills', 'total-recall', 'openwiki') ||
@@ -388,6 +439,11 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
388
439
  version: entry?.version || readSkillMeta(destDir).version,
389
440
  content_hash: contentHash,
390
441
  registry_hash: entry?.content_hash || null,
442
+ ...(layered ? {
443
+ layered: true,
444
+ core_hash: hashSkillLayer(destDir, 'core'),
445
+ repo_hash: hashSkillLayer(destDir, 'repo'),
446
+ } : {}),
391
447
  adapted: Boolean(adaptResult.adapted),
392
448
  installed_at: new Date().toISOString(),
393
449
  };
@@ -413,6 +469,42 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
413
469
  };
414
470
  }
415
471
 
472
+ function installDetail(entry, inst) {
473
+ const exists = fs.existsSync(path.join(inst.path, 'SKILL.md'));
474
+ // Only explicitly deployed/adopted installs are layered. Old install-map rows
475
+ // under a now-layered catalog entry are legacy copies: not adoption consent,
476
+ // and their content hash says nothing about the plugin-owned core.
477
+ const layered = Boolean(inst.layered);
478
+ const legacyUnderLayered = Boolean(entry?.layered) && !layered;
479
+ const liveHash = exists ? hashSkillContent(inst.path) : null;
480
+ const liveCoreHash = layered && exists ? hashSkillLayer(inst.path, 'core') : null;
481
+ const liveRepoHash = layered && exists ? hashSkillLayer(inst.path, 'repo') : null;
482
+ const drift = layered
483
+ ? liveCoreHash !== (entry?.core_hash || inst.core_hash)
484
+ : legacyUnderLayered
485
+ ? false
486
+ : exists && entry?.content_hash
487
+ ? liveHash !== entry.content_hash
488
+ : exists && inst.registry_hash
489
+ ? liveHash !== inst.registry_hash
490
+ : false;
491
+ return {
492
+ ...inst,
493
+ exists,
494
+ live_hash: liveHash,
495
+ ...(layered ? {
496
+ core_hash: liveCoreHash,
497
+ repo_hash: liveRepoHash,
498
+ repo_changed: liveRepoHash !== inst.repo_hash,
499
+ } : {}),
500
+ ...(legacyUnderLayered ? { adopted: false } : {}),
501
+ drift,
502
+ // Repo-layer contract checks apply to any layered install whose core ships a config contract.
503
+ ...(layered && exists && fs.existsSync(path.join(inst.path, 'core', 'config.schema.json'))
504
+ ? { contract: checkSkillLayerContract(inst.path) } : {}),
505
+ };
506
+ }
507
+
416
508
  /**
417
509
  * Status of a skill: registry entry + installs + drift.
418
510
  */
@@ -422,20 +514,13 @@ export function skillStatus(brainDir, skillId) {
422
514
  const installs = registry.installs.filter((i) => i.skill_id === skillId);
423
515
 
424
516
  const installDetails = installs.map((inst) => {
425
- const exists = fs.existsSync(path.join(inst.path, 'SKILL.md'));
426
- const liveHash = exists ? hashSkillContent(inst.path) : null;
427
- const drift =
428
- exists && entry?.content_hash
429
- ? liveHash !== entry.content_hash
430
- : exists && inst.registry_hash
431
- ? liveHash !== inst.registry_hash
432
- : false;
433
- return {
434
- ...inst,
435
- exists,
436
- live_hash: liveHash,
437
- drift,
438
- };
517
+ try {
518
+ return installDetail(entry, inst);
519
+ } catch (err) {
520
+ // One unreadable install (a symlinked layer, a moved repo) must not hide
521
+ // the status of every other install.
522
+ return { ...inst, exists: fs.existsSync(inst.path), error: err.message, drift: false };
523
+ }
439
524
  });
440
525
 
441
526
  // Local brain skills dir presence
@@ -452,6 +537,7 @@ export function skillStatus(brainDir, skillId) {
452
537
  installs: installDetails,
453
538
  install_count: installDetails.length,
454
539
  any_drift: installDetails.some((i) => i.drift),
540
+ any_contract_failure: installDetails.some((i) => i.contract && !i.contract.ok),
455
541
  };
456
542
  }
457
543
 
@@ -550,6 +636,9 @@ export function isRepoScopedSkill(brainDir, skillId) {
550
636
  const registry = loadRegistry(brainDir);
551
637
  const entry = registry.skills?.[skillId];
552
638
  if (entry?.repo_scoped) return true;
639
+ // A layered catalog explicitly owns only core/. Repo-layer SKILL.md files
640
+ // may be repo_scoped and are never candidates for cross-repo propagation.
641
+ if (entry?.layered) return false;
553
642
  const candidates = [
554
643
  entry?.source_path,
555
644
  ...registry.installs.filter((install) => install.skill_id === skillId).map((install) => install.path),
@@ -858,7 +947,7 @@ export function discoverAllSkills(
858
947
  source: hit.path,
859
948
  source_type: 'discovered',
860
949
  });
861
- } else if (readSkillMeta(hit.path).repo_scoped && !before.repo_scoped) {
950
+ } else if (!before.layered && readSkillMeta(hit.path).repo_scoped && !before.repo_scoped) {
862
951
  const scopedRegistry = loadRegistry(brainDir);
863
952
  scopedRegistry.skills[hit.id].repo_scoped = true;
864
953
  scopedRegistry.skills[hit.id].updated_at = new Date().toISOString();
@@ -871,6 +960,7 @@ export function discoverAllSkills(
871
960
  }
872
961
 
873
962
  const registry = loadRegistry(brainDir);
963
+ if (registry.skills[hit.id]?.layered) continue; // layered installs require explicit deploy/adoption
874
964
  const exists = registry.installs.some(
875
965
  (i) => i.skill_id === hit.id && path.resolve(i.repo || '') === path.resolve(repo),
876
966
  );
@@ -920,7 +1010,7 @@ export function skillMtime(skillDir) {
920
1010
  export function replaceSkillDir(
921
1011
  src,
922
1012
  dest,
923
- { dryRun = false, preserveExisting = true, fsImpl = fs, copyFn = copySkillDir } = {},
1013
+ { dryRun = false, preserveExisting = true, requiredFile = 'SKILL.md', fsImpl = fs, copyFn = copySkillDir } = {},
924
1014
  ) {
925
1015
  if (dryRun) return { dryRun: true, src, dest };
926
1016
  if (!fsImpl.existsSync(src)) throw new Error(`Source missing: ${src}`);
@@ -954,8 +1044,8 @@ export function replaceSkillDir(
954
1044
  copyFn(dest, stage);
955
1045
  }
956
1046
  copyFn(src, stage);
957
- if (!fsImpl.existsSync(path.join(stage, 'SKILL.md'))) {
958
- throw new Error(`Staged skill is missing SKILL.md: ${stage}`);
1047
+ if (requiredFile && !fsImpl.existsSync(path.join(stage, requiredFile))) {
1048
+ throw new Error(`Staged skill is missing ${requiredFile}: ${stage}`);
959
1049
  }
960
1050
 
961
1051
  if (fsImpl.existsSync(dest)) {
@@ -1004,12 +1094,13 @@ export function collectSkillLocations(brainDir, skillId) {
1004
1094
  role: 'source',
1005
1095
  path: entry.source_path,
1006
1096
  repo: path.resolve(entry.source_path, '..', '..', '..'),
1007
- hash: hashSkillContent(entry.source_path),
1097
+ hash: entry.layered ? hashSkillLayer(entry.source_path, 'core') : hashSkillContent(entry.source_path),
1008
1098
  mtime: skillMtime(entry.source_path),
1009
1099
  });
1010
1100
  }
1011
1101
 
1012
1102
  for (const inst of registry.installs.filter((i) => i.skill_id === skillId)) {
1103
+ if (entry?.layered && !inst.layered) continue; // old discovery rows are not adoption consent
1013
1104
  if (!fs.existsSync(path.join(inst.path, 'SKILL.md'))) continue;
1014
1105
  // skip duplicate of source
1015
1106
  if (locations.some((l) => samePhysicalPath(l.path, inst.path))) continue;
@@ -1017,13 +1108,13 @@ export function collectSkillLocations(brainDir, skillId) {
1017
1108
  role: 'install',
1018
1109
  path: inst.path,
1019
1110
  repo: inst.repo,
1020
- hash: hashSkillContent(inst.path),
1111
+ hash: entry?.layered ? hashSkillLayer(inst.path, 'core') : hashSkillContent(inst.path),
1021
1112
  mtime: skillMtime(inst.path),
1022
1113
  });
1023
1114
  }
1024
1115
 
1025
1116
  // Also scan known repos for this skill id not yet mapped
1026
- for (const repo of loadKnownRepoRoots(brainDir)) {
1117
+ for (const repo of entry?.layered ? [] : loadKnownRepoRoots(brainDir)) {
1027
1118
  for (const hit of discoverSkillsInRepo(repo)) {
1028
1119
  if (hit.id !== skillId) continue;
1029
1120
  if (locations.some((l) => samePhysicalPath(l.path, hit.path))) continue;
@@ -1031,7 +1122,7 @@ export function collectSkillLocations(brainDir, skillId) {
1031
1122
  role: 'discovered',
1032
1123
  path: hit.path,
1033
1124
  repo: hit.repo,
1034
- hash: hashSkillContent(hit.path),
1125
+ hash: entry?.layered ? hashSkillLayer(hit.path, 'core') : hashSkillContent(hit.path),
1035
1126
  mtime: skillMtime(hit.path),
1036
1127
  });
1037
1128
  }
@@ -1091,19 +1182,29 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
1091
1182
  return { skillId, skipped: true, reason: 'repo_scoped', locations: locations.length };
1092
1183
  }
1093
1184
 
1185
+ const layered = Boolean(entry?.layered);
1186
+ const source = layered ? locations.find((loc) => loc.role === 'source') : null;
1187
+ if (layered && !source) return { skillId, error: 'layered skill has no catalog source' };
1094
1188
  const hashes = new Set(locations.map((l) => l.hash).filter(Boolean));
1095
- if (hashes.size <= 1 && locations.length >= 1) {
1189
+ if (hashes.size <= 1 && locations.every((loc) => loc.hash)) {
1096
1190
  // already in sync — still refresh install map hashes
1097
1191
  if (!dryRun && entry) {
1098
1192
  const registry = loadRegistry(brainDir);
1099
1193
  if (registry.skills[skillId]) {
1100
- registry.skills[skillId].content_hash = locations[0].hash;
1194
+ if (layered) registry.skills[skillId].core_hash = source.hash;
1195
+ else registry.skills[skillId].content_hash = locations[0].hash;
1101
1196
  registry.skills[skillId].updated_at = new Date().toISOString();
1102
1197
  }
1103
1198
  for (const inst of registry.installs.filter((i) => i.skill_id === skillId)) {
1199
+ if (layered && !inst.layered) continue;
1104
1200
  if (fs.existsSync(inst.path)) {
1105
1201
  inst.content_hash = hashSkillContent(inst.path);
1106
- inst.registry_hash = locations[0].hash;
1202
+ inst.registry_hash = layered ? entry.content_hash : locations[0].hash;
1203
+ if (layered) {
1204
+ inst.layered = true;
1205
+ inst.core_hash = hashSkillLayer(inst.path, 'core');
1206
+ inst.repo_hash = hashSkillLayer(inst.path, 'repo');
1207
+ }
1107
1208
  }
1108
1209
  }
1109
1210
  saveRegistry(brainDir, registry);
@@ -1117,7 +1218,7 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
1117
1218
  };
1118
1219
  }
1119
1220
 
1120
- const winner = pickSyncWinner(locations, prefer);
1221
+ const winner = source || pickSyncWinner(locations, prefer);
1121
1222
  if (!winner) {
1122
1223
  return { skillId, error: 'no winner', locations };
1123
1224
  }
@@ -1133,13 +1234,22 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
1133
1234
  role: loc.role,
1134
1235
  });
1135
1236
  if (!dryRun) {
1136
- replaceSkillDir(winner.path, loc.path);
1237
+ if (layered) {
1238
+ replaceSkillDir(path.join(winner.path, 'core'), path.join(loc.path, 'core'), {
1239
+ requiredFile: null,
1240
+ preserveExisting: false,
1241
+ });
1242
+ } else {
1243
+ replaceSkillDir(winner.path, loc.path);
1244
+ }
1137
1245
  }
1138
1246
  }
1139
1247
 
1140
1248
  // Catalog + install map
1141
1249
  if (!dryRun) {
1142
- if (promoteWinner) {
1250
+ if (layered) {
1251
+ // The repo layer is never promoted into the plugin-owned catalog source.
1252
+ } else if (promoteWinner) {
1143
1253
  try {
1144
1254
  registerSkill(brainDir, winner.path, {
1145
1255
  source: winner.path,
@@ -1163,6 +1273,7 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
1163
1273
 
1164
1274
  if (registry.skills[skillId]) {
1165
1275
  registry.skills[skillId].content_hash = catalogHash;
1276
+ if (layered) registry.skills[skillId].core_hash = source.hash;
1166
1277
  registry.skills[skillId].updated_at = new Date().toISOString();
1167
1278
  }
1168
1279
 
@@ -1182,6 +1293,11 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
1182
1293
  version: registry.skills[skillId]?.version || '0.0.0',
1183
1294
  content_hash: hashSkillContent(loc.path),
1184
1295
  registry_hash: catalogHash,
1296
+ ...(layered ? {
1297
+ layered: true,
1298
+ core_hash: hashSkillLayer(loc.path, 'core'),
1299
+ repo_hash: hashSkillLayer(loc.path, 'repo'),
1300
+ } : {}),
1185
1301
  adapted: false,
1186
1302
  installed_at: new Date().toISOString(),
1187
1303
  synced_at: new Date().toISOString(),
@@ -13,7 +13,32 @@ import {
13
13
  import { assemblePluginContexts } from './plugin-context.mjs';
14
14
  import { selectResearchBriefs, formatResearchBriefs } from './research-surface.mjs';
15
15
  import { loadQueue } from './research-queue.mjs';
16
- import { brainDir as globalBrainDir } from './config.mjs';
16
+ import { brainDir as globalBrainDir, globalAgentDir } from './config.mjs';
17
+ import { listSurfaceCommands, buildCommandsSection, surfaceInputsHash } from './command-surface.mjs';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ // Long-lived processes (server, daemon, vault watcher) import this module once.
21
+ // If the rule builder is edited or upgraded after they start, their in-memory
22
+ // copy is stale and would overwrite fresh instruction surfaces with old logic.
23
+ const SURFACE_SOURCE = fileURLToPath(import.meta.url);
24
+ const sourceHash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
25
+ const LOADED_SURFACE_HASH = (() => { try { return sourceHash(SURFACE_SOURCE); } catch { return null; } })();
26
+
27
+ /** True when surface.mjs on disk differs from the copy this process loaded. */
28
+ export function isSurfaceCodeStale(file = SURFACE_SOURCE, loadedHash = LOADED_SURFACE_HASH) {
29
+ if (!loadedHash) return false;
30
+ try { return sourceHash(file) !== loadedHash; } catch { return false; }
31
+ }
32
+
33
+ /** Scope dirs searched for composable commands, in dispatcher order (project, then global). */
34
+ export function commandDirsFor(skillsDir) {
35
+ const dirs = [];
36
+ if (skillsDir) dirs.push({ scope: 'project', dir: path.join(path.dirname(skillsDir), 'commands') });
37
+ dirs.push({ scope: 'global', dir: path.join(globalAgentDir, 'commands') });
38
+ // Compiling the global brain itself: its commands are the global ones.
39
+ if (dirs.length === 2 && path.resolve(dirs[0].dir) === path.resolve(dirs[1].dir)) dirs.shift();
40
+ return dirs;
41
+ }
17
42
 
18
43
  /**
19
44
  * Extract [[slug]] wikilink references and relative Markdown link targets from body text.
@@ -386,7 +411,10 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
386
411
  const bAbs = b.priority === 'absolute' ? 10 : 0;
387
412
  const aScore = (a.importance || 3) + aAbs;
388
413
  const bScore = (b.importance || 3) + bAbs;
389
- return bScore - aScore;
414
+ if (bScore !== aScore) return bScore - aScore;
415
+ // Tie-break by recency so a freshly saved rule is not silently ranked
416
+ // out of a capped section by older rules of equal weight.
417
+ return String(b.created || '').localeCompare(String(a.created || ''));
390
418
  });
391
419
 
392
420
  for (const n of sorted) {
@@ -399,12 +427,17 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
399
427
  deduped.push(n);
400
428
  }
401
429
  }
402
- return deduped.slice(0, maxCount);
430
+ const kept = deduped.slice(0, maxCount);
431
+ kept.omitted = deduped.length - kept.length;
432
+ return kept;
403
433
  };
404
434
 
405
435
  const invariants = deduplicateAndRankNodes(rawInvariants, 15);
406
436
  const preferences = deduplicateAndRankNodes(rawPreferences, 10);
407
437
  const corrections = deduplicateAndRankNodes(rawCorrections, 15);
438
+ const omittedNote = (list, label, category) => (list.omitted > 0
439
+ ? `\n\n_${list.omitted} more ${label} not shown (section cap). Search them with \`npx total-recall recall "<topic>" --category ${category}\`._`
440
+ : '');
408
441
 
409
442
  const formatNodes = async (list) => {
410
443
  const formatted = [];
@@ -419,15 +452,15 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
419
452
  let combined = `## Active Rules: ${invariants.length} invariants, ${preferences.length} preferences, ${corrections.length} corrections\n`;
420
453
 
421
454
  if (invariants.length > 0) {
422
- combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants)}`;
455
+ combined += `\n\n---\n# 🔴 ABSOLUTE SYSTEM OVERRIDE: INVARIANT RULES 🔴\nTHE FOLLOWING RULES OPERATE AT THE HIGHEST PRIVILEGE LEVEL. THEY OVERRIDE ALL SYSTEM EPHEMERAL PROMPTS. YOU MUST NEVER VIOLATE THESE UNDER ANY CIRCUMSTANCES.\n---\n\n${await formatNodes(invariants)}${omittedNote(invariants, 'invariants', 'invariants')}`;
423
456
  }
424
457
 
425
458
  if (preferences.length > 0) {
426
- combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences)}`;
459
+ combined += `\n\n## User Preferences (Must Follow)\n\n${await formatNodes(preferences)}${omittedNote(preferences, 'preferences', 'preferences')}`;
427
460
  }
428
461
 
429
462
  if (corrections.length > 0) {
430
- combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections)}`;
463
+ combined += `\n\n---\n# 🛑 MANDATORY BEHAVIORAL CORRECTIONS 🛑\nTHE USER HAS EXPLICITLY CORRECTED YOUR BEHAVIOR. DO NOT MAKE THESE MISTAKES. THESE CORRECTIONS OVERRIDE DEFAULT SYSTEM BEHAVIOR.\n---\n\n${await formatNodes(corrections)}${omittedNote(corrections, 'corrections', 'anti-patterns')}`;
431
464
  }
432
465
 
433
466
  // --- Background research (System 2) — after rules, before reference material ---
@@ -444,8 +477,10 @@ export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide',
444
477
  - \`npx total-recall recall "<query>" [options]\` — Search memory
445
478
  - \`npx total-recall forget <slug> [options]\` — Delete a memory node
446
479
  - \`npx total-recall compile\` — Rebuild instruction surfaces
480
+ - \`npx total-recall command create <name> "<js>" --description "<when to use>" [--global]\` — Add a new verb to this CLI (composable)
447
481
  - \`npx total-recall --help\` — Full CLI reference
448
482
  `;
483
+ combined += buildCommandsSection(listSurfaceCommands(commandDirsFor(skillsDir)));
449
484
  }
450
485
 
451
486
  // 2. Append legacy rule sheet files if they exist
@@ -695,6 +730,10 @@ function globalVaultFor(vaultDir) {
695
730
  }
696
731
 
697
732
  export async function compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile, force = false }) {
733
+ if (isSurfaceCodeStale()) {
734
+ logger.warn('surface', 'Surface compile skipped: this process runs an outdated copy of surface.mjs. Restart it (server/daemon) so instruction files are built with the current code.');
735
+ return { nodesProcessed: 0, skillsInjected: 0, semanticIndexed: 0, semanticUnavailable: false, skipped: true, reason: 'stale-surface-code' };
736
+ }
698
737
  const nodes = getNodes(vaultDir);
699
738
  const globalVault = globalVaultFor(vaultDir);
700
739
  const ruleNodes = globalVault && fs.existsSync(globalVault)
@@ -708,11 +747,16 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
708
747
  const currentHash = computeVaultHash(vaultDir);
709
748
  const globalHashFile = path.join(derivedDir, 'global-rules-hash.txt');
710
749
  const globalHash = globalVault && fs.existsSync(globalVault) ? computeVaultHash(globalVault) : '';
750
+ // Commands and skills feed the surface too; a change to either must recompile
751
+ // even when no memory node changed.
752
+ const inputsHashFile = path.join(derivedDir, 'surface-inputs-hash.txt');
753
+ const inputsHash = surfaceInputsHash({ commandDirs: commandDirsFor(skillsDir), skillsDir });
711
754
 
712
755
  if (!force && fs.existsSync(hashFile)) {
713
756
  const storedHash = fs.readFileSync(hashFile, 'utf8').trim();
714
757
  const storedGlobal = fs.existsSync(globalHashFile) ? fs.readFileSync(globalHashFile, 'utf8').trim() : '';
715
- if (storedHash === currentHash && storedGlobal === globalHash) {
758
+ const storedInputs = fs.existsSync(inputsHashFile) ? fs.readFileSync(inputsHashFile, 'utf8').trim() : '';
759
+ if (storedHash === currentHash && storedGlobal === globalHash && storedInputs === inputsHash) {
716
760
  return {
717
761
  nodesProcessed: nodes.length,
718
762
  skillsInjected: 0,
@@ -791,6 +835,7 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
791
835
  // 5. Write vault hash + projection manifest
792
836
  atomicWrite(hashFile, currentHash);
793
837
  atomicWrite(globalHashFile, globalHash);
838
+ atomicWrite(inputsHashFile, inputsHash);
794
839
  writeProjectionManifest(derivedDir, currentHash);
795
840
 
796
841
  // 6. Generate live OKF Index and Log
@@ -217,7 +217,7 @@ router.get('/api/usage/providers', requireAuth, async (req, res) => {
217
217
  router.post('/api/daemon/restart', requireAuth, requireScope('config:write'), async (_req, res) => {
218
218
  try {
219
219
  const { stopDaemon, startDaemon } = await import('../../core/daemon-control.mjs');
220
- stopDaemon();
220
+ await stopDaemon();
221
221
  const pid = startDaemon();
222
222
  res.json({ success: true, message: `Daemon restarted successfully (PID ${pid})`, pid });
223
223
  } catch (err) {
@@ -1,25 +0,0 @@
1
- # Code Quality Report
2
-
3
- Prints the latest report from the `code-quality` skill's background gates in the current repository. It only reads results; it never starts a typecheck or lint run. Requires `.agent/skills/code-quality/` in the repository.
4
-
5
- **Use cases:** software-development
6
-
7
- ## Install
8
-
9
- ```bash
10
- npx total-recall plugin install code-quality
11
- ```
12
-
13
- ## Use
14
-
15
- ```bash
16
- npx total-recall code-quality report
17
- ```
18
-
19
- ## Share with your other nodes
20
-
21
- ```bash
22
- npx total-recall plugin share code-quality
23
- # on another node on the mesh:
24
- npx total-recall plugin install peer:<this-node>/code-quality
25
- ```
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
-
6
- // Prints the latest report written by the code-quality skill's background
7
- // gates. The report script only reads results; it never starts a check.
8
- export async function run() {
9
- const scriptPath = path.resolve(process.cwd(), '.agent/skills/code-quality/scripts/report.mjs');
10
- if (!fs.existsSync(scriptPath)) {
11
- console.error(`No code-quality skill in ${process.cwd()} (expected ${scriptPath}).`);
12
- process.exitCode = 1;
13
- return;
14
- }
15
- try {
16
- const out = execFileSync(process.execPath, [scriptPath], { encoding: 'utf8', cwd: process.cwd() });
17
- console.log(out);
18
- } catch (err) {
19
- if (err.stdout) console.log(err.stdout);
20
- console.error(err.stderr || err.message);
21
- process.exitCode = err.status || 1;
22
- }
23
- }
24
-
25
- export default run;
@@ -1,17 +0,0 @@
1
- {
2
- "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
- "id": "code-quality",
4
- "name": "Code Quality Report",
5
- "version": "1.1.0",
6
- "description": "Shows the latest code-quality gate report for a repository that has the code-quality skill installed.",
7
- "author": "Total Recall",
8
- "license": "MIT",
9
- "use_cases": ["software-development"],
10
- "cli": {
11
- "command": "code-quality",
12
- "handler": "./cli.mjs",
13
- "subcommands": [
14
- { "name": "report", "description": "Print the latest gate report from .agent/skills/code-quality" }
15
- ]
16
- }
17
- }