total-recall-brain 3.47.0 → 3.47.2

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 (73) hide show
  1. package/bin/total-recall.mjs +5 -0
  2. package/package.json +1 -1
  3. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +4 -4
  4. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -2
  5. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +30 -5
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/skills/auth/SKILL.md +35 -0
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/home.js +494 -79
  8. package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +4 -4
  9. package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +3 -2
  10. package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +33 -8
  11. package/scaffold/.agent/skills/total-recall/plugins/automations/skills/automations/SKILL.md +29 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/chunks/dream-consolidation.md +17 -0
  13. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  14. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -2
  15. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +31 -4
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/skills/code-quality/SKILL.md +27 -0
  17. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +17 -3
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -2
  22. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +24 -4
  23. package/scaffold/.agent/skills/total-recall/plugins/embeddings/skills/embeddings/SKILL.md +25 -0
  24. package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +4 -4
  25. package/scaffold/.agent/skills/total-recall/plugins/git/package.json +3 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +293 -58
  27. package/scaffold/.agent/skills/total-recall/plugins/git/skills/git/SKILL.md +36 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +47 -14
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/skills/mesh/SKILL.md +36 -0
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +6 -3
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +6 -6
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +5 -2
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +4 -2
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +28 -9
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +8 -2
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +2 -2
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +7 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-selection.mjs +59 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +2 -2
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +3 -3
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +11 -5
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +50 -6
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/chunks/session-controls.md +16 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +56 -7
  48. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  49. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  50. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +199 -36
  51. package/scaffold/.agent/skills/total-recall/plugins/openwiki/skills/openwiki/SKILL.md +32 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +34 -8
  53. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +6 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +15 -7
  55. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +4 -3
  56. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  57. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  58. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +22 -5
  59. package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +3 -2
  60. package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +169 -37
  61. package/scaffold/.agent/skills/total-recall/plugins/ssss/skills/ssss/SKILL.md +30 -0
  62. package/src/cli/plugin/create.mjs +2 -1
  63. package/src/cli/plugin/monetization.mjs +66 -32
  64. package/src/cli/plugin/search.mjs +25 -3
  65. package/src/cli/preflight.mjs +40 -6
  66. package/src/cli/update.mjs +20 -1
  67. package/src/core/automatic-brain-updates.mjs +8 -4
  68. package/src/core/brain-updates.mjs +25 -6
  69. package/src/core/gate-mode.mjs +13 -24
  70. package/src/core/plugin-distribution.mjs +35 -1
  71. package/src/core/preflight-gate.mjs +28 -8
  72. package/src/core/preflight-receipt.mjs +11 -0
  73. package/src/core/release-defaults-handoff.mjs +89 -0
@@ -7,7 +7,7 @@ import { globalBrainDirFor, isBrainEnabled } from '../core/brain-registry.mjs';
7
7
  import { GATE_MODES, gateConfigPath, projectBrainDir, readGateLayers, resolveGateMode, writeGateMode } from '../core/gate-mode.mjs';
8
8
  import { installHarnessHooks, loadGateAdapters } from '../core/harness-hooks.mjs';
9
9
  import { ackDirectory, capsuleVerb, decide, gatePackageBroken, isAckCommand, isCheckCommand, isRouteCommand, isSubagentPayload, nativeToolOf, sessionOf, SWEEP_CONSULT_MS, whenGateFails } from '../core/preflight-gate.mjs';
10
- import { ACK_COMMAND, acknowledge, checkAgentReceipt, checkAnyReceipt, findRoot, forgetCapsuleDeliveries, lastArrivalMs, lastCapsuleDelivery, lastConsultMs, lastRoutedMs, lastRouteWarnedMs, lastSessionAck, markArrival, markDelegated, markRouted, markRouteWarned, markSessionAck, noteCaller, recordDecision, sessionHasStanding, sessionKey, readDecisions, takeCaller } from '../core/preflight-receipt.mjs';
10
+ import { ACK_COMMAND, acknowledge, checkAgentReceipt, checkAnyReceipt, findRoot, forgetCapsuleDeliveries, lastArrivalMs, lastCapsuleDelivery, lastConsultMs, lastRoutedMs, lastRouteSelection, lastRouteWarnedMs, lastSessionAck, markArrival, markDelegated, markRouted, markRouteSelection, markRouteWarned, markSessionAck, noteCaller, recordDecision, sessionHasStanding, sessionKey, readDecisions, takeCaller } from '../core/preflight-receipt.mjs';
11
11
 
12
12
  const USAGE = `Usage: total-recall preflight <command>
13
13
  ack --task "<task>" [--action read,edit,test] [--json] Record a receipt after context ready:true and rules verify, and print the capsule.
@@ -19,9 +19,9 @@ const USAGE = `Usage: total-recall preflight <command>
19
19
  remind [--json] Prompt-hook entry: advisory text when the receipt is missing; always exits 0
20
20
  session-start Session-hook entry: after compaction or clear, the next ack prints the capsule; always exits 0
21
21
  install [--harness <id>|--all] [--dry-run] Install the gate for a harness, or for every harness here. Install and update do this. No off switch.
22
- mode [off|warn|enforce|clear] [--plugin <id>|--router] [--repo] [--json]
23
- Show or set what happens when work goes around a plugin. Default is warn.
24
- The preflight receipt itself has no off switch.
22
+ mode [enforce] [--plugin <id>|--router] [--repo] [--json]
23
+ Show gate enforcement status. All plugin and routing gates are permanently
24
+ enforced; off, warn, and clear bypasses are disabled.
25
25
  coverage [--dir <folder>] [--json] List plugins as declared or missing; exit 1 on a gap. Default: the shared plugin folder
26
26
  verify | report Harness adapters, owned by the cli-agents plugin
27
27
  Aliases: total-recall tr-preflight --ack | --check. A receipt lasts 30 minutes (config preflight_max_age_minutes).
@@ -397,6 +397,33 @@ function projectBrainRoot(dir) {
397
397
  return brain && isBrainEnabled(brain.brainDir) ? brain.projectRoot : null;
398
398
  }
399
399
 
400
+ /** How long a route's selection binds when the receipt names no window: the default receipt lifetime. */
401
+ const ROUTE_SELECTION_MS = 30 * 60000;
402
+
403
+ /**
404
+ * Records which harness a router command selected, so the gate can deny writes from the others.
405
+ * Called after the router plugin's own command runs, with what it printed. Output that is not a
406
+ * route result records nothing. Bookkeeping only: it never throws.
407
+ * @returns {string|null|undefined} the selected harness, null when the route chose none, undefined when nothing was recorded
408
+ */
409
+ export function noteRouteSelection({ manifest, args = [], stdout = '', cwd = process.cwd() } = {}) {
410
+ try {
411
+ const router = manifest?.gate?.router;
412
+ if (!router?.pattern) return undefined;
413
+ const command = `${manifest.cli?.command || manifest.id} ${args.map(String).join(' ')}`;
414
+ if (!new RegExp(`(?:^|\\s)(?:${router.pattern})(\\s|$)`).test(command)) return undefined;
415
+ const text = String(stdout || '').trim();
416
+ let result;
417
+ try { result = JSON.parse(text); } catch { result = JSON.parse(text.split('\n').pop()); }
418
+ if (!result || typeof result !== 'object' || !('selected' in result)) return undefined;
419
+ const selected = typeof result.selected === 'string' && result.selected ? result.selected : null;
420
+ const root = findRoot(cwd);
421
+ markRouted(root);
422
+ markRouteSelection(root, selected);
423
+ return selected;
424
+ } catch { return undefined; }
425
+ }
426
+
400
427
  /** The gate's answer to a call. Nothing here may throw, because an answer is all that keeps its repair reachable. */
401
428
  function evaluate(call, options = {}) {
402
429
  try {
@@ -450,6 +477,13 @@ function judge(call, { harness, readablePaths, session = null, subagent = false,
450
477
  ownerHealthy: (plugin, use) => ownerRuns(plugin, use, root),
451
478
  // A subagent or a run the router started reports to whoever routed the work.
452
479
  dispatched: runByRouter || subagent || Boolean(session && !ownAck),
480
+ // Only a named harness can be told apart from the one the router chose.
481
+ harness: harness && harness !== 'generic' ? harness : null,
482
+ routerRun: runByRouter,
483
+ selection: (receipt) => {
484
+ const chosen = lastRouteSelection(root);
485
+ return chosen && Date.now() - chosen.at < (limit ?? receipt?.limitMs ?? ROUTE_SELECTION_MS) ? chosen : null;
486
+ },
453
487
  check,
454
488
  recentConsult: () => {
455
489
  const now = Date.now();
@@ -569,14 +603,14 @@ async function mode(args) {
569
603
  const repo = args.includes('--repo');
570
604
  const brain = repo ? projectBrainDir(root) : globalBrainDirFor();
571
605
  if (value !== undefined) {
572
- if (value !== 'clear' && !GATE_MODES.includes(value)) return fail(`Unknown mode "${value}". Choose ${GATE_MODES.join(', ')} or clear.`);
606
+ if (value !== 'enforce') return fail(`✖ Plugin and routing gates are permanently enforced and have no "${value}" bypass.`);
573
607
  const plugin = flag(args, '--plugin');
574
608
  const router = args.includes('--router');
575
609
  if (plugin && router) return fail('Choose --plugin or --router, not both.');
576
610
  if (plugin && !plugins.includes(plugin)) return fail(`No plugin here declares raw commands under "${plugin}". Declared: ${plugins.join(', ') || 'none'}.`);
577
611
  if (router && !declared.router) return fail('No plugin here declares a router.');
578
612
  if (repo && !fs.existsSync(brain)) return fail(`${root} has no project brain to hold this setting. Run total-recall init there, or leave off --repo.`);
579
- writeGateMode(brain, { mode: value === 'clear' ? null : value, plugin, router });
613
+ writeGateMode(brain, { mode: 'enforce', plugin, router });
580
614
  }
581
615
  const layers = readGateLayers({ globalBrainDir: globalBrainDirFor(), root });
582
616
  const { adapters } = await loadGateAdapters(root);
@@ -17,7 +17,7 @@ import {
17
17
  } from '../core/package-auto-update.mjs';
18
18
  import { brainDir } from '../core/config.mjs';
19
19
  import { installAutoPull } from '../core/autopull-install.mjs';
20
- import { updateAllBrains } from '../core/brain-updates.mjs';
20
+ import { updateAllBrains, ensureBrainDefaults } from '../core/brain-updates.mjs';
21
21
  import { installHarnessHooks } from '../core/harness-hooks.mjs';
22
22
  import { automaticUpdateStatus, runAutomaticBrainUpdates } from '../core/automatic-brain-updates.mjs';
23
23
  import { runningServerStatus } from '../core/running-server.mjs';
@@ -38,6 +38,7 @@ export function parseArgs(args) {
38
38
  automatic: false,
39
39
  status: false,
40
40
  localOnly: false,
41
+ defaults: false,
41
42
  };
42
43
  for (let i = 0; i < args.length; i++) {
43
44
  const a = args[i];
@@ -55,6 +56,7 @@ export function parseArgs(args) {
55
56
  else if (a === '--automatic') opts.automatic = true;
56
57
  else if (a === '--status') opts.status = true;
57
58
  else if (a === '--local-only') opts.localOnly = true;
59
+ else if (a === '--defaults') opts.defaults = true;
58
60
  }
59
61
  return opts;
60
62
  }
@@ -85,6 +87,12 @@ function printHelp() {
85
87
  installed plugin from its recorded source and refresh surfaces. Where a
86
88
  per-user service (LaunchAgent, systemd user unit) runs the server from
87
89
  the global install, also restart a stale server and verify /health.
90
+ When this run installed a release newer than the running code, that release
91
+ refreshes the defaults with its own code (update --defaults) in the same pass.
92
+ total-recall update --defaults [--repo <path>] [--json]
93
+ Refresh the shared default plugins from this release's bundled copies (a
94
+ linked owner checkout is left alone) and provision every registered brain.
95
+ Exit 1 when any brain fails.
88
96
  total-recall update --status [--json]
89
97
  Read the actual last automatic maintenance report without starting work.
90
98
  Also reports what the server on this host runs against the release.
@@ -121,6 +129,17 @@ export default async function update(args = []) {
121
129
  console.log(JSON.stringify(status,null,2));
122
130
  return status;
123
131
  }
132
+ if (opts.defaults) {
133
+ const report = ensureBrainDefaults({ brainDir, roots: opts.repos });
134
+ if (opts.json) console.log(JSON.stringify(report, null, 2));
135
+ else {
136
+ console.log(`\n Default plugins: ${report.results.length - report.failed} brain(s) ready, ${report.failed} failed`);
137
+ for (const row of report.results) console.log(` ${row.status === 'ready' ? '✓' : '✗'} ${row.root}${row.error ? `: ${row.error}` : ''}`);
138
+ console.log('');
139
+ }
140
+ if (report.failed) process.exitCode = 1;
141
+ return report;
142
+ }
124
143
  if(opts.automatic){
125
144
  const report=await runAutomaticBrainUpdates({brainDir,force:opts.force,fleet:!opts.localOnly});
126
145
  console.log(JSON.stringify(report,null,2));
@@ -14,6 +14,7 @@ import { updateSourceBrainPackages } from './source-brain-updates.mjs';
14
14
  import { updateInstalledPlugins } from './plugin-updates.mjs';
15
15
  import { runningServerStatus } from './running-server.mjs';
16
16
  import { replaceStaleServer } from './server-restart.mjs';
17
+ import { handOffDefaultsToRelease } from './release-defaults-handoff.mjs';
17
18
 
18
19
  export function automaticUpdateStatus(brainDir) {
19
20
  let last;
@@ -38,7 +39,7 @@ export function startAutomaticBrainUpdates({brainDir,intervalMs=60*60*1000,run=r
38
39
  return {tick,stop(){stopped=true;clearInterval(timer);}};
39
40
  }
40
41
 
41
- export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,skillMaintenance=maintainPortableSkills,serverCheck=runningServerStatus,replaceServer=replaceStaleServer,now=Date.now}={}) {
42
+ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,defaultsHandoff=handOffDefaultsToRelease,skillMaintenance=maintainPortableSkills,serverCheck=runningServerStatus,replaceServer=replaceStaleServer,now=Date.now}={}) {
42
43
  if(!force && !isPackageAutoUpdateEnabled())return {skipped:true,reason:'disabled',results:[]};
43
44
  const file=path.join(brainDir,'memory-derived/automatic-brain-updates.json');
44
45
  let saved;try{saved=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}
@@ -62,10 +63,13 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
62
63
  const summary=await update({brainDir,dryRun:false,ensureDefaults:true});
63
64
  const global=summary.versions?await globalUpdate({versions:summary.versions}):{failed:1,results:[],error:'Cannot maintain global packages without verified published versions'};
64
65
  const sources=summary.versions?await sourceUpdate({brainDir,versions:summary.versions}):{failed:1,results:[]};
66
+ // The defaults above came from the running code, which is the release being replaced. A newer
67
+ // release installed just now refreshes them with its own code and bundled copies, this pass.
68
+ const defaults=await defaultsHandoff({global,sources});
65
69
  // The shared plugin folder follows the source each plugin was installed from (git, mesh peer, directory).
66
70
  const plugins=await pluginUpdate({roots:[path.resolve(brainDir,'../../..')]});
67
71
  const mesh=fleet && summary.versions?await meshUpdate({versions:summary.versions,version:summary.versions['total-recall-brain']}):{failed:0,results:[],scope:'local-only'};
68
- const changed=summary.updated>0 || plugins.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
72
+ const changed=summary.updated>0 || plugins.updated>0 || defaults.status==='refreshed' || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
69
73
  const skills=await skillMaintenance({brainDir});
70
74
  const surfaces=await compile({brainDir,force:changed || skills.updated > 0});
71
75
  // Last, so a replaced server starts on everything this run moved. Where maintenance cannot
@@ -79,8 +83,8 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
79
83
  // local_failed is this node's own maintenance. Peers judge a node by it, not by
80
84
  // `failed`, which also counts the node's view of its peers: judging by that made
81
85
  // every fleet pass mark every peer failed forever, each because the others had.
82
- const local_failed=(summary.failed || 0)+global.failed+sources.failed+plugins.failed+(skills.failed || 0)+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length)+serverFailed;
83
- report={...summary,global,sources,plugins,mesh,server,skills,surfaces,local_failed,failed:local_failed+mesh.failed,checkedAt:now()};
86
+ const local_failed=(summary.failed || 0)+global.failed+sources.failed+(defaults.status==='failed'?1:0)+plugins.failed+(skills.failed || 0)+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length)+serverFailed;
87
+ report={...summary,global,sources,defaults,plugins,mesh,server,skills,surfaces,local_failed,failed:local_failed+mesh.failed,checkedAt:now()};
84
88
  }catch(error){report={failed:1,error:error.message,checkedAt:now(),results:[]};}
85
89
  const temporary=`${file}.${process.pid}.tmp`;fs.writeFileSync(temporary,JSON.stringify(report,null,2));fs.renameSync(temporary,file);
86
90
  return report;
@@ -217,18 +217,37 @@ export function installEnv(env = process.env) {
217
217
  return kept;
218
218
  }
219
219
 
220
+ /** The brains maintenance provisions: each update root that has a brain, plus the selected brain. */
221
+ function maintainedBrains(roots, brainDir) {
222
+ const brains = roots.map(root => ({ root, brain: path.join(root, '.agent', 'skills', 'total-recall') }))
223
+ .filter(({ brain }) => fs.existsSync(brain));
224
+ if (brainDir) brains.push({ root: brainDir, brain: brainDir });
225
+ return brains;
226
+ }
227
+
228
+ /**
229
+ * Refresh the shared default plugins from this package's bundled copies and provision every
230
+ * registered brain, reporting each brain's outcome instead of throwing. `update --defaults` runs
231
+ * this, so a release maintenance has just installed can do it with its own code and its own
232
+ * bundled copies (release-defaults-handoff.mjs).
233
+ */
234
+ export function ensureBrainDefaults(opts = {}, { ensure = ensureRequiredPlugins } = {}) {
235
+ const roots = listUpdateRoots(opts).map(row => row.root);
236
+ const results = maintainedBrains(roots, opts.brainDir).map(({ root, brain }) => {
237
+ try { ensure(brain); return { root, status: 'ready' }; }
238
+ catch (error) { return { root, status: 'failed', error: error.message }; }
239
+ });
240
+ return { failed: results.filter(row => row.status === 'failed').length, results };
241
+ }
242
+
220
243
  export async function updateAllBrains(opts = {}) {
221
244
  const dryRun = opts.dryRun !== false;
222
245
  const installMissing = opts.installMissing === true;
223
246
  const exec = opts.exec || spawnSync;
224
247
  const roots = listUpdateRoots(opts).map(row => row.root);
225
248
  const errors = [];
226
- const brains = roots.map(root => ({ root, brain: path.join(root, '.agent', 'skills', 'total-recall') }))
227
- .filter(({ brain }) => fs.existsSync(brain));
228
- if (opts.brainDir) {
229
- brains.push({ root: opts.brainDir, brain: opts.brainDir });
230
- roots.push(opts.brainDir);
231
- }
249
+ const brains = maintainedBrains(roots, opts.brainDir);
250
+ if (opts.brainDir) roots.push(opts.brainDir);
232
251
  for (const { root, brain } of brains) {
233
252
  if (!dryRun && opts.ensureDefaults === true) {
234
253
  try { ensureRequiredPlugins(brain); }
@@ -1,19 +1,15 @@
1
1
  /**
2
2
  * Modes for the gate rules that steer work toward a plugin: raw commands a plugin owns, and
3
- * routing. `off` does nothing, `warn` lets the call run and tells the agent, `enforce` blocks.
4
- * The preflight receipt is not one of these rules and has no mode.
3
+ * routing. Gates are permanently enforced (`enforce`): there is no `off` or `warn` bypass.
5
4
  *
6
- * Settings live in `config/gate.json` in a brain:
7
- * { "default": "warn", "router": "enforce", "plugins": { "git": "enforce" } }
8
- * The repository's brain wins over the machine's. This runs on every tool call, so it reads the
9
- * files directly, and an unreadable file or unknown value counts as unset: a bad settings file
10
- * must never be what blocks work.
5
+ * Settings live in `config/gate.json` in a brain. Any legacy `off` or `warn` values in
6
+ * `gate.json` are ignored so enforcement cannot be disabled on disk.
11
7
  */
12
8
  import fs from 'node:fs';
13
9
  import path from 'node:path';
14
10
 
15
- export const GATE_MODES = ['off', 'warn', 'enforce'];
16
- export const DEFAULT_GATE_MODE = 'warn';
11
+ export const GATE_MODES = ['enforce'];
12
+ export const DEFAULT_GATE_MODE = 'enforce';
17
13
 
18
14
  export const gateConfigPath = (brainDir) => path.join(brainDir, 'config', 'gate.json');
19
15
  export const projectBrainDir = (root) => path.join(root, '.agent', 'skills', 'total-recall');
@@ -30,39 +26,32 @@ export function readGateLayers({ globalBrainDir, root }) {
30
26
  return [readSettings(projectBrainDir(root)), readSettings(globalBrainDir)];
31
27
  }
32
28
 
33
- const known = (value) => (GATE_MODES.includes(value) ? value : undefined);
34
-
35
29
  /**
36
30
  * @param {object[]} layers from readGateLayers
37
31
  * @param {'router'|'owned'} kind
38
32
  * @param {string} [plugin] the owning plugin, for `owned`
39
33
  */
40
- export function resolveGateMode(layers, kind, plugin) {
41
- for (const layer of layers) {
42
- const specific = kind === 'router' ? known(layer.router) : known(layer.plugins?.[plugin]);
43
- const mode = specific ?? known(layer.default);
44
- if (mode) return mode;
45
- }
34
+ export function resolveGateMode(_layers, _kind, _plugin) {
46
35
  return DEFAULT_GATE_MODE;
47
36
  }
48
37
 
49
38
  /**
50
- * Change one setting and leave the others alone. A `mode` of null removes the setting, so
51
- * that level follows the next layer again.
39
+ * Persist `enforce` mode. `off`, `warn`, and clearing are refused.
52
40
  * @param {string} brainDir
53
41
  * @param {{ mode: string|null, plugin?: string, router?: boolean }} change
54
42
  */
55
43
  export function writeGateMode(brainDir, { mode, plugin, router }) {
44
+ if (mode !== 'enforce') {
45
+ throw new Error('Plugin and routing gates are permanently enforced and cannot be set to off, warn, or cleared.');
46
+ }
56
47
  const settings = readSettings(brainDir);
57
48
  if (router) {
58
- if (mode === null) delete settings.router; else settings.router = mode;
49
+ settings.router = 'enforce';
59
50
  } else if (plugin) {
60
51
  settings.plugins = settings.plugins && typeof settings.plugins === 'object' ? settings.plugins : {};
61
- if (mode === null) delete settings.plugins[plugin]; else settings.plugins[plugin] = mode;
62
- } else if (mode === null) {
63
- delete settings.default;
52
+ settings.plugins[plugin] = 'enforce';
64
53
  } else {
65
- settings.default = mode;
54
+ settings.default = 'enforce';
66
55
  }
67
56
  const file = gateConfigPath(brainDir);
68
57
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -12,7 +12,41 @@ function describePluginWithShare(plugin, projectRoot) {
12
12
  return result;
13
13
  }
14
14
 
15
- async function resolveBundle(source, { peerDeps, publicDeps } = {}) {
15
+ async function resolveStoreBundle(id, { storeUrl = process.env.TR_STORE_URL || 'http://127.0.0.1:3000', fetchImpl = fetch, licenseKey } = {}) {
16
+ const base = storeUrl.replace(/\/+$/, '');
17
+ const headers = {};
18
+ const resolvedKey = licenseKey || process.env[`${id.toUpperCase().replace(/-/g, '_')}_LICENSE_KEY`] || process.env.TR_LICENSE_KEY;
19
+ if (resolvedKey) headers['x-license-key'] = resolvedKey;
20
+ const listRes = await fetchImpl(`${base}/api/store/plugins?q=${encodeURIComponent(id)}`);
21
+ if (!listRes.ok) throw new Error(`Store catalog unavailable (HTTP ${listRes.status})`);
22
+ const listData = await listRes.json();
23
+ const entry = (listData.plugins || []).find((p) => p.id === id);
24
+ if (!entry) throw new Error(`Plugin '${id}' not found in Store catalog`);
25
+ const bundleRes = await fetchImpl(`${base}/api/store/plugins/${encodeURIComponent(id)}/bundle`, { headers });
26
+ if (!bundleRes.ok) {
27
+ let reason = `HTTP ${bundleRes.status}`;
28
+ try { const err = await bundleRes.json(); if (err?.error) reason = err.error; } catch {}
29
+ throw new Error(`Store install denied for '${id}': ${reason}`);
30
+ }
31
+ const bundle = Buffer.from(await bundleRes.arrayBuffer());
32
+ const sha256 = bundleRes.headers?.get?.('x-plugin-sha256') || entry.sha256;
33
+ return { bundle, sha256, sourceRecord: { kind: 'store', ref: `store:${id}`, store_url: base }, expectedId: id };
34
+ }
35
+
36
+ async function resolveBundle(source, { peerDeps, publicDeps, storeDeps } = {}) {
37
+ if (typeof source === 'string' && source.startsWith('store:')) {
38
+ const id = source.slice('store:'.length).trim();
39
+ if (!id) throw new Error('Missing plugin id in store:<id>');
40
+ const { bundle, sha256, sourceRecord, expectedId } = await resolveStoreBundle(id, storeDeps);
41
+ const decoded = decodeBundle(bundle, sha256 ? { expectedSha256: sha256 } : {});
42
+ const entry = decoded.entries.find(e => e.path === 'plugin.json');
43
+ if (!entry) throw new Error('Store plugin bundle has no plugin.json');
44
+ const manifest = JSON.parse(entry.data.toString('utf8'));
45
+ const validation = validatePluginManifest(manifest);
46
+ if (!validation.valid) throw new Error(`Invalid plugin manifest: ${validation.errors.join('; ')}`);
47
+ if (manifest.id !== expectedId) throw new Error(`Requested '${expectedId}' but bundle contained '${manifest.id}'`);
48
+ return { manifest, entries: decoded.entries, sha256: decoded.sha256, source: sourceRecord };
49
+ }
16
50
  const peerModule = typeof source === 'string' && source.startsWith('peer:')
17
51
  ? await import('./plugin-peers.mjs') : null;
18
52
  const peer = peerModule?.parsePeerSource(source);
@@ -29,6 +29,10 @@
29
29
  * (`gate.router`). Reading, searching and native subagents stay open, as does any
30
30
  * plain Total Recall command, so routing can always run. A process the router
31
31
  * started has already been routed. It warns once per receipt, not on every call.
32
+ * 8. With a receipt, once the router has selected a harness for this repository, a write from
33
+ * any other harness is denied, whatever the mode. Only a run the router started may write
34
+ * for it. A selection lasts as long as a receipt, and routing again replaces it. A call with
35
+ * no named harness is not judged by this rule, because it cannot be told apart.
32
36
  * Rules 6 and 7 follow a mode (see gate-mode.mjs): `off`, `warn` (the default) or `enforce`.
33
37
  * `enforce` blocks only while the owner passes its health check, so a broken plugin can
34
38
  * never trap an agent. Warnings are statements of fact, because harnesses treat
@@ -154,6 +158,13 @@ export function ackDirectory(command, cwd) {
154
158
  return target ? path.resolve(cwd || process.cwd(), target.replace(/^(["'])([\s\S]*)\1$/, '$2')) : cwd;
155
159
  }
156
160
 
161
+ export function isBlockedGrep(command) {
162
+ const text = runnableText(command);
163
+ if (/(^|[\s;&|(])(grep|egrep|fgrep|rg|ripgrep|ag)(\s|$)/.test(text)) return true;
164
+ if (/(^|[\s;&|(])git\s+grep(\s|$)/.test(text)) return true;
165
+ return false;
166
+ }
167
+
157
168
  /** A repository-wide search used to answer a question, as the DSH `tr-first.sh` rule defined it. */
158
169
  export function isRepoSweep(command) {
159
170
  const c = String(command || '');
@@ -235,10 +246,7 @@ const staleness = (receipt) => (receipt.ageMs > receipt.limitMs
235
246
  * What a rule that steers work toward a plugin does with a call it matched: nothing (null),
236
247
  * a denial, or a warning to carry.
237
248
  */
238
- function steer(deps, { kind, plugin, name, reason, note, use }) {
239
- const mode = deps.mode ? deps.mode(kind, plugin) : DEFAULT_GATE_MODE;
240
- if (mode === 'off') return null;
241
- if (mode !== 'enforce') return { name, warning: note };
249
+ function steer(deps, { plugin, name, reason, note, use }) {
242
250
  if (!deps.ownerHealthy || deps.ownerHealthy(plugin, use)) return deny(name, reason, use);
243
251
  return { name, warning: `${note} The ${plugin} plugin failed its health check, so this call was not blocked.` };
244
252
  }
@@ -287,7 +295,7 @@ export function whenGateFails(call, err) {
287
295
 
288
296
  /**
289
297
  * @param {{ tool: string, command?: string, path?: string, cwd?: string }} call
290
- * @param {{ check: () => { ok: boolean, problems: string[], ageMs?: number|null, limitMs?: number }, recentConsult?: () => boolean, readablePaths?: RegExp[], ownedCommands?: () => { plugin: string, pattern: string, use: string }[], router?: () => ({ plugin: string, pattern: string, use: string } | null), routed?: (receipt: object) => boolean, routeWarned?: (receipt: object) => boolean, sessionAcknowledged?: (receipt: object) => boolean, sessionLapsed?: () => boolean, arrival?: () => (string | null), dispatched?: boolean, mode?: (kind: 'router'|'owned', plugin: string) => string, ownerHealthy?: (plugin: string, use: string) => boolean }} deps
298
+ * @param {{ check: () => { ok: boolean, problems: string[], ageMs?: number|null, limitMs?: number }, recentConsult?: () => boolean, readablePaths?: RegExp[], ownedCommands?: () => { plugin: string, pattern: string, use: string }[], router?: () => ({ plugin: string, pattern: string, use: string } | null), routed?: (receipt: object) => boolean, routeWarned?: (receipt: object) => boolean, sessionAcknowledged?: (receipt: object) => boolean, sessionLapsed?: () => boolean, arrival?: () => (string | null), dispatched?: boolean, harness?: string|null, routerRun?: boolean, selection?: (receipt: object) => ({ harness: string|null, at: number } | null), mode?: (kind: 'router'|'owned', plugin: string) => string, ownerHealthy?: (plugin: string, use: string) => boolean }} deps
291
299
  */
292
300
  export function decide(call, deps) {
293
301
  try {
@@ -313,14 +321,26 @@ export function decide(call, deps) {
313
321
  : `this session has not acknowledged (directory: ${call.cwd || 'unknown'}). A receipt from another agent does not count.`;
314
322
  return deny('receipt:session', `TR-PREFLIGHT gate: ${state} ${nextStep(call)}`);
315
323
  }
316
- if (tool === 'shell' && isRepoSweep(call.command) && !(deps.recentConsult ? deps.recentConsult() : false)) {
317
- return deny('sweep', 'TR-PREFLIGHT gate: do not sweep the repository for an answer; Total Recall owns it. Run `total-recall help "<task>"` to find the owning command, or `total-recall recall "<topic>" --local`, then repeat this call.',
324
+ if (tool === 'shell' && isBlockedGrep(call.command)) {
325
+ return deny('grep', 'TR-PREFLIGHT gate: `grep`/`rg`/`ag` is blocked. Use Total Recall OpenWiki (`total-recall openwiki` / `openwiki/`) or `total-recall recall "<topic>" --local` instead.',
326
+ 'total-recall openwiki hubs --json');
327
+ }
328
+ if ((tool === 'search' || (tool === 'shell' && isRepoSweep(call.command))) && !(deps.recentConsult ? deps.recentConsult() : false)) {
329
+ return deny('sweep', 'TR-PREFLIGHT gate: do not sweep or search the repository before consulting Total Recall / OpenWiki. Run `total-recall openwiki hubs --json`, `total-recall recall "<topic>" --local`, or `total-recall help "<task>"` first.',
318
330
  'total-recall help "<task>"');
319
331
  }
332
+ const router = deps.router ? deps.router() : null;
333
+ if (tool === 'write' && deps.harness && !deps.routerRun) {
334
+ const chosen = deps.selection ? deps.selection(receipt) : null;
335
+ if (chosen?.harness && chosen.harness !== deps.harness) {
336
+ const by = router?.plugin || 'the router';
337
+ return deny('route:selected', `TR-PREFLIGHT gate: ${by} selected ${chosen.harness} for this task, so ${deps.harness} may not write here. Only a run ${by} started may write: hand the task over with its --execute form, or route it again.`,
338
+ router?.use || 'route the task again');
339
+ }
340
+ }
320
341
  const notes = [];
321
342
  const arrival = deps.arrival ? deps.arrival() : null;
322
343
  if (arrival) notes.push({ name: 'arrival', warning: arrival });
323
- const router = deps.router ? deps.router() : null;
324
344
  if (router && !deps.dispatched) {
325
345
  const open = ['read', 'search', 'web', 'agent'].includes(tool) || (tool === 'shell' && isPlainTotalRecall(call.command));
326
346
  if (!open && !(deps.routed ? deps.routed(receipt.receipt) : false)) {
@@ -339,6 +339,17 @@ export function lastRouteWarnedMs(root, { home = os.homedir(), session = null }
339
339
  return readStamp(routeWarnedPath(root, home, session));
340
340
  }
341
341
 
342
+ const selectionPath = (root, home) => path.join(stateDir(home), scoped('route-selected', root));
343
+
344
+ /** The harness the router chose for work in this repository, or null when it chose none. */
345
+ export function markRouteSelection(root, harness, { home = os.homedir(), now = Date.now() } = {}) {
346
+ writeBookkeeping(selectionPath(root, home), (target) => fs.writeFileSync(target, JSON.stringify({ at: now, harness: harness || null, root }), { mode: 0o600 }));
347
+ }
348
+
349
+ export function lastRouteSelection(root, { home = os.homedir() } = {}) {
350
+ return newestRecord(selectionPath(root, home));
351
+ }
352
+
342
353
  const sessionAckPath = (session, home) => path.join(stateDir(home), `session-ack-${sessionKey(session)}.stamp`);
343
354
  const delegatedPath = (root, home) => path.join(stateDir(home), scoped('delegated', root));
344
355
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Automatic maintenance refreshes the default plugins before it installs a new release: the code
3
+ * that runs it is the old release, and so are its bundled copies. A default is replaced only by a
4
+ * higher bundled version, so that pass changed nothing and a release's defaults arrived one pass
5
+ * (about an hour) later. After the global and source updates, the newest installed release of this
6
+ * package refreshes the defaults itself, with its own code and its own bundled copies.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { PACKAGE_ROOT } from './plugin-loader.mjs';
11
+ import { comparePackageVersions } from './package-version.mjs';
12
+ import { findLinkedSourceCheckout } from './global-brain-updates.mjs';
13
+ import { runBounded } from './startup-health.mjs';
14
+
15
+ const PACKAGE = 'total-recall-brain';
16
+
17
+ function readVersion(dir) {
18
+ try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version || null; } catch { return null; }
19
+ }
20
+
21
+ /**
22
+ * The release this process runs, read once at import. A source update can fast-forward this
23
+ * checkout during the run, so reading the file again later would report the new release.
24
+ */
25
+ export const RUNNING_VERSION = readVersion(PACKAGE_ROOT);
26
+
27
+ /** Installed releases this run may have moved: the global install and source checkouts it updated. */
28
+ export function installedReleases({ global, sources } = {}) {
29
+ const dirs = new Set();
30
+ const add = dir => { try { dirs.add(fs.realpathSync(dir)); } catch {} };
31
+ if (global?.root) {
32
+ const direct = path.join(global.root, PACKAGE);
33
+ const dir = fs.existsSync(direct) ? direct : findLinkedSourceCheckout(global.root, PACKAGE);
34
+ if (dir) add(dir);
35
+ }
36
+ for (const row of sources?.results || []) {
37
+ if (row?.package === PACKAGE && row.status === 'updated' && row.root) add(row.root);
38
+ }
39
+ return [...dirs]
40
+ .map(dir => ({ dir, version: readVersion(dir) }))
41
+ .filter(release => release.version && fs.existsSync(path.join(release.dir, 'bin', 'total-recall.mjs')));
42
+ }
43
+
44
+ /** The JSON report `update --defaults --json` prints, even after other output on stdout. */
45
+ export function parseDefaultsReport(text) {
46
+ const trimmed = String(text || '').trim();
47
+ try { return JSON.parse(trimmed); } catch {}
48
+ const start = trimmed.lastIndexOf('\n{\n');
49
+ if (start >= 0) { try { return JSON.parse(trimmed.slice(start + 1)); } catch {} }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * When an installed release is newer than the running code, run its `update --defaults` and report
55
+ * what it did. Never throws: the outcome is a row in the maintenance report.
56
+ * @returns {Promise<{status:'current'|'refreshed'|'failed', running?:string|null, installed?:string, version?:string, from?:string|null, results?:object[], error?:string}>}
57
+ */
58
+ export async function handOffDefaultsToRelease({ global, sources, cwd = process.cwd(), running = RUNNING_VERSION,
59
+ execPath = process.execPath, run = runBounded, timeoutMs = 10 * 60 * 1000 } = {}) {
60
+ let newest = null;
61
+ try {
62
+ for (const release of installedReleases({ global, sources })) {
63
+ if (!newest || comparePackageVersions(release.version, newest.version) === 1) newest = release;
64
+ }
65
+ } catch (error) {
66
+ return { status: 'failed', running, error: `Installed releases could not be read: ${error.message}` };
67
+ }
68
+ if (!newest || comparePackageVersions(newest.version, running) !== 1) {
69
+ return { status: 'current', running, ...(newest ? { installed: newest.version } : {}) };
70
+ }
71
+ const handoff = { version: newest.version, from: running };
72
+ let result;
73
+ try {
74
+ result = await run(execPath, [path.join(newest.dir, 'bin', 'total-recall.mjs'), 'update', '--defaults', '--json'],
75
+ { cwd, timeoutMs, maxBytes: 1024 * 1024 });
76
+ } catch (error) {
77
+ return { status: 'failed', ...handoff, error: `update --defaults could not run: ${error.message}` };
78
+ }
79
+ const report = parseDefaultsReport(result?.output);
80
+ if (result?.code === 0 && report && report.failed === 0) {
81
+ return { status: 'refreshed', ...handoff, results: report.results || [] };
82
+ }
83
+ const failures = (report?.results || []).filter(row => row.status === 'failed').map(row => `${row.root}: ${row.error}`);
84
+ const error = result?.reason ? `update --defaults stopped: ${result.reason}`
85
+ : failures.length ? failures.join('; ')
86
+ : !report ? `update --defaults printed no report (exit ${result?.code ?? 'unknown'}): ${String(result?.stderr || result?.output || '').trim().slice(-1000)}`
87
+ : `update --defaults exited ${result?.code}`;
88
+ return { status: 'failed', ...handoff, error, ...(report?.results ? { results: report.results } : {}) };
89
+ }