@animalabs/agent-framework 0.26.1 → 0.27.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 (276) hide show
  1. package/README.md +57 -7
  2. package/dist/src/agent.d.ts +263 -22
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +531 -54
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +1 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +33 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +35 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/framework.d.ts +829 -156
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3906 -1008
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +309 -46
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +2117 -395
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -2
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -1
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  30. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  31. package/dist/src/gate/recipe-reconcile.js +325 -0
  32. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +68 -8
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/index.d.ts +6 -4
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -2
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +1152 -434
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  44. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  45. package/dist/src/mcpl/conversation-router.js +29 -15
  46. package/dist/src/mcpl/conversation-router.js.map +1 -1
  47. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  48. package/dist/src/mcpl/convert-block.js +14 -2
  49. package/dist/src/mcpl/convert-block.js.map +1 -1
  50. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  51. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  52. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  53. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  54. package/dist/src/mcpl/index.d.ts +5 -1
  55. package/dist/src/mcpl/index.d.ts.map +1 -1
  56. package/dist/src/mcpl/index.js +4 -0
  57. package/dist/src/mcpl/index.js.map +1 -1
  58. package/dist/src/mcpl/inference-router.d.ts +14 -2
  59. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  60. package/dist/src/mcpl/inference-router.js +73 -28
  61. package/dist/src/mcpl/inference-router.js.map +1 -1
  62. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  63. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-grammar.js +6 -0
  65. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  66. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  67. package/dist/src/mcpl/protocol-family.js +3 -0
  68. package/dist/src/mcpl/protocol-family.js.map +1 -1
  69. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  70. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  71. package/dist/src/mcpl/push-coalescer.js +33 -11
  72. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  73. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  74. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  75. package/dist/src/mcpl/reference-fetcher.js +177 -145
  76. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  77. package/dist/src/mcpl/references.d.ts +380 -46
  78. package/dist/src/mcpl/references.d.ts.map +1 -1
  79. package/dist/src/mcpl/references.js +737 -78
  80. package/dist/src/mcpl/references.js.map +1 -1
  81. package/dist/src/mcpl/server-connection.d.ts +82 -3
  82. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  83. package/dist/src/mcpl/server-connection.js +132 -7
  84. package/dist/src/mcpl/server-connection.js.map +1 -1
  85. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  86. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  87. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  88. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  89. package/dist/src/mcpl/tool-presence.d.ts +259 -0
  90. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  91. package/dist/src/mcpl/tool-presence.js +628 -0
  92. package/dist/src/mcpl/tool-presence.js.map +1 -0
  93. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  94. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  95. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  96. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  97. package/dist/src/mcpl/tool-signature.d.ts +39 -0
  98. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  99. package/dist/src/mcpl/tool-signature.js +292 -0
  100. package/dist/src/mcpl/tool-signature.js.map +1 -0
  101. package/dist/src/mcpl/toolset-host.d.ts +200 -0
  102. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  103. package/dist/src/mcpl/toolset-host.js +621 -0
  104. package/dist/src/mcpl/toolset-host.js.map +1 -0
  105. package/dist/src/mcpl/toolsets-module.d.ts +80 -0
  106. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  107. package/dist/src/mcpl/toolsets-module.js +431 -0
  108. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  109. package/dist/src/mcpl/transport.d.ts +2 -2
  110. package/dist/src/mcpl/transport.d.ts.map +1 -1
  111. package/dist/src/mcpl/transport.js +3 -2
  112. package/dist/src/mcpl/transport.js.map +1 -1
  113. package/dist/src/mcpl/types.d.ts +21 -1
  114. package/dist/src/mcpl/types.d.ts.map +1 -1
  115. package/dist/src/mcpl/types.js.map +1 -1
  116. package/dist/src/mcpl/visible-content.d.ts +7 -9
  117. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  118. package/dist/src/mcpl/visible-content.js +21 -9
  119. package/dist/src/mcpl/visible-content.js.map +1 -1
  120. package/dist/src/module-registry.d.ts +23 -8
  121. package/dist/src/module-registry.d.ts.map +1 -1
  122. package/dist/src/module-registry.js +68 -15
  123. package/dist/src/module-registry.js.map +1 -1
  124. package/dist/src/modules/discord/index.js +1 -1
  125. package/dist/src/modules/discord/index.js.map +1 -1
  126. package/dist/src/modules/history/index.d.ts +2 -1
  127. package/dist/src/modules/history/index.d.ts.map +1 -1
  128. package/dist/src/modules/history/index.js +51 -17
  129. package/dist/src/modules/history/index.js.map +1 -1
  130. package/dist/src/modules/history/semantic.d.ts +108 -6
  131. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  132. package/dist/src/modules/history/semantic.js +273 -57
  133. package/dist/src/modules/history/semantic.js.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.d.ts +181 -0
  135. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  136. package/dist/src/modules/workspace/direct-write.js +384 -0
  137. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  138. package/dist/src/modules/workspace/edit-text.d.ts +26 -12
  139. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  140. package/dist/src/modules/workspace/edit-text.js +58 -28
  141. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  142. package/dist/src/modules/workspace/effects.d.ts +10 -0
  143. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  144. package/dist/src/modules/workspace/effects.js +1 -1
  145. package/dist/src/modules/workspace/effects.js.map +1 -1
  146. package/dist/src/modules/workspace/index.d.ts +246 -27
  147. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  148. package/dist/src/modules/workspace/index.js +1216 -236
  149. package/dist/src/modules/workspace/index.js.map +1 -1
  150. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  151. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  152. package/dist/src/modules/workspace/read-page.js +213 -0
  153. package/dist/src/modules/workspace/read-page.js.map +1 -0
  154. package/dist/src/modules/workspace/result-text.d.ts +67 -0
  155. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  156. package/dist/src/modules/workspace/result-text.js +94 -0
  157. package/dist/src/modules/workspace/result-text.js.map +1 -0
  158. package/dist/src/modules/workspace/text.d.ts +29 -12
  159. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/text.js +35 -33
  161. package/dist/src/modules/workspace/text.js.map +1 -1
  162. package/dist/src/modules/workspace/types.d.ts +41 -17
  163. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/types.js.map +1 -1
  165. package/dist/src/sent-rows.d.ts +59 -0
  166. package/dist/src/sent-rows.d.ts.map +1 -0
  167. package/dist/src/sent-rows.js +145 -0
  168. package/dist/src/sent-rows.js.map +1 -0
  169. package/dist/src/silent-heartbeat.d.ts +17 -10
  170. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  171. package/dist/src/silent-heartbeat.js.map +1 -1
  172. package/dist/src/stable-hash.d.ts +3 -0
  173. package/dist/src/stable-hash.d.ts.map +1 -0
  174. package/dist/src/stable-hash.js +15 -0
  175. package/dist/src/stable-hash.js.map +1 -0
  176. package/dist/src/stored-injections.d.ts +99 -0
  177. package/dist/src/stored-injections.d.ts.map +1 -0
  178. package/dist/src/stored-injections.js +106 -0
  179. package/dist/src/stored-injections.js.map +1 -0
  180. package/dist/src/text-as-stored.d.ts +4 -3
  181. package/dist/src/text-as-stored.d.ts.map +1 -1
  182. package/dist/src/text-as-stored.js +4 -3
  183. package/dist/src/text-as-stored.js.map +1 -1
  184. package/dist/src/thinking-drops.d.ts +81 -0
  185. package/dist/src/thinking-drops.d.ts.map +1 -0
  186. package/dist/src/thinking-drops.js +103 -0
  187. package/dist/src/thinking-drops.js.map +1 -0
  188. package/dist/src/tool-image-ledger.d.ts +9 -8
  189. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  190. package/dist/src/tool-image-ledger.js +9 -8
  191. package/dist/src/tool-image-ledger.js.map +1 -1
  192. package/dist/src/tool-presentation.d.ts.map +1 -1
  193. package/dist/src/tool-presentation.js +3 -1
  194. package/dist/src/tool-presentation.js.map +1 -1
  195. package/dist/src/tool-result-guard.d.ts +4 -2
  196. package/dist/src/tool-result-guard.d.ts.map +1 -1
  197. package/dist/src/tool-result-guard.js +21 -6
  198. package/dist/src/tool-result-guard.js.map +1 -1
  199. package/dist/src/tool-result-history.d.ts +186 -21
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +428 -89
  202. package/dist/src/tool-result-history.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +17 -9
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +84 -37
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/types/agent.d.ts +186 -7
  208. package/dist/src/types/agent.d.ts.map +1 -1
  209. package/dist/src/types/events.d.ts +19 -2
  210. package/dist/src/types/events.d.ts.map +1 -1
  211. package/dist/src/types/framework.d.ts +24 -3
  212. package/dist/src/types/framework.d.ts.map +1 -1
  213. package/dist/src/types/module.d.ts +90 -7
  214. package/dist/src/types/module.d.ts.map +1 -1
  215. package/dist/src/types/trace.d.ts +57 -2
  216. package/dist/src/types/trace.d.ts.map +1 -1
  217. package/package.json +4 -3
  218. package/src/agent.ts +593 -60
  219. package/src/api/server.ts +1 -2
  220. package/src/auto-continue.ts +42 -0
  221. package/src/code-execution/isolate/scripts.ts +11 -3
  222. package/src/framework.ts +4004 -1026
  223. package/src/gate/event-gate.ts +2315 -389
  224. package/src/gate/index.ts +2 -1
  225. package/src/gate/recipe-reconcile.ts +355 -0
  226. package/src/gate/types.ts +70 -8
  227. package/src/index.ts +7 -4
  228. package/src/mcpl/channel-registry.ts +1220 -467
  229. package/src/mcpl/conversation-router.ts +34 -16
  230. package/src/mcpl/convert-block.ts +14 -1
  231. package/src/mcpl/hook-orchestrator.ts +33 -6
  232. package/src/mcpl/index.ts +6 -1
  233. package/src/mcpl/inference-router.ts +79 -34
  234. package/src/mcpl/prose-grammar.ts +7 -0
  235. package/src/mcpl/protocol-family.ts +2 -0
  236. package/src/mcpl/push-coalescer.ts +51 -13
  237. package/src/mcpl/reference-fetcher.ts +201 -162
  238. package/src/mcpl/references.ts +852 -78
  239. package/src/mcpl/server-connection.ts +145 -7
  240. package/src/mcpl/tool-lifecycle.ts +21 -0
  241. package/src/mcpl/tool-presence.ts +650 -0
  242. package/src/mcpl/tool-result-normalize.ts +18 -6
  243. package/src/mcpl/tool-signature.ts +285 -0
  244. package/src/mcpl/toolset-host.ts +637 -0
  245. package/src/mcpl/toolsets-module.ts +451 -0
  246. package/src/mcpl/transport.ts +3 -2
  247. package/src/mcpl/types.ts +22 -1
  248. package/src/mcpl/visible-content.ts +22 -9
  249. package/src/module-registry.ts +77 -24
  250. package/src/modules/discord/index.ts +1 -1
  251. package/src/modules/history/index.ts +48 -18
  252. package/src/modules/history/semantic.ts +308 -54
  253. package/src/modules/workspace/direct-write.ts +425 -0
  254. package/src/modules/workspace/edit-text.ts +82 -35
  255. package/src/modules/workspace/effects.ts +1 -1
  256. package/src/modules/workspace/index.ts +1274 -241
  257. package/src/modules/workspace/read-page.ts +222 -0
  258. package/src/modules/workspace/result-text.ts +119 -0
  259. package/src/modules/workspace/text.ts +58 -32
  260. package/src/modules/workspace/types.ts +41 -17
  261. package/src/sent-rows.ts +162 -0
  262. package/src/silent-heartbeat.ts +17 -10
  263. package/src/stable-hash.ts +14 -0
  264. package/src/stored-injections.ts +131 -0
  265. package/src/text-as-stored.ts +4 -3
  266. package/src/thinking-drops.ts +141 -0
  267. package/src/tool-image-ledger.ts +9 -8
  268. package/src/tool-presentation.ts +3 -1
  269. package/src/tool-result-guard.ts +19 -6
  270. package/src/tool-result-history.ts +488 -88
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +19 -2
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -10,9 +10,11 @@
10
10
  * and are always dispatched to modules — the gate decides whether to spend
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
- import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { basename, dirname, join } from 'node:path';
15
15
  import { GateScript } from './gate-script.js';
16
+ import { canonical, duplicateName, lastRecipePath, noChanges, reconcileLine, reconcileWithRecipe } from './recipe-reconcile.js';
17
+ import { closestNames, isNearMiss } from '../closest-names.js';
16
18
  import { MAX_TIMER_MS } from '../deadline-timer.js';
17
19
  import { safeSlice } from '../safe-slice.js';
18
20
  // ---------------------------------------------------------------------------
@@ -38,6 +40,19 @@ function parseGateJson(text) {
38
40
  const MIN_DEBOUNCE_MS = 100;
39
41
  /** Maximum debounce value (ms). */
40
42
  const MAX_DEBOUNCE_MS = 300_000;
43
+ /** Whether a check made at `last` is still fresh at `now`, less than the
44
+ * reload throttle ago. A clock stepped backwards since makes it stale, so
45
+ * the next check runs rather than wait for the clock to catch up. */
46
+ function checkedRecently(now, last) {
47
+ const elapsed = now - last;
48
+ return elapsed >= 0 && elapsed < RELOAD_THROTTLE_MS;
49
+ }
50
+ /** A read that failed because nothing is at the path. Any other failure
51
+ * (EACCES on a directory, ELOOP, ENOTDIR) is a file that's there but can't
52
+ * be read. */
53
+ function isMissing(err) {
54
+ return err?.code === 'ENOENT';
55
+ }
41
56
  // ---------------------------------------------------------------------------
42
57
  // Default config
43
58
  // ---------------------------------------------------------------------------
@@ -110,10 +125,35 @@ export function wakeProvenance(events) {
110
125
  ...(channelId ? { channelId } : {}),
111
126
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
112
127
  ...(pick.addressed ? { addressed: true } : {}),
113
- ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
128
+ ...(pick.addressed && pick.routeChannelId
129
+ ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
130
+ : {}),
114
131
  at: pick.timestamp,
115
132
  };
116
133
  }
134
+ /** A `[Gate: …]` line's reference to messages already in the window: how
135
+ * many, in which channel, and how long ago the oldest arrived, as of `now`. */
136
+ function referenceLine(count, channel, oldest, now) {
137
+ const age = Math.round((now - oldest) / 1000);
138
+ return `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
139
+ `(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
140
+ `batched wake, not new content`;
141
+ }
142
+ /** A handed batched-wake line's text (WakeLine), as it reads when written at
143
+ * `now`: the gate's words, its message's age counted to then. */
144
+ export function wakeLineText(line, now) {
145
+ return `[Gate: 1 event matched]\n\n${referenceLine(1, line.shown, line.at, now)}`;
146
+ }
147
+ /** The event's own stored entry, as the framework is asked about it. */
148
+ function entryRef(event) {
149
+ return {
150
+ eventType: event.eventType,
151
+ ...(event.serverId !== undefined ? { serverId: event.serverId } : {}),
152
+ ...(event.channelId !== undefined ? { channelId: event.channelId } : {}),
153
+ ...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
154
+ ...(event.eventId !== undefined ? { eventId: event.eventId } : {}),
155
+ };
156
+ }
117
157
  /**
118
158
  * Default route-channel resolution when the host supplies none: a
119
159
  * channel-incoming event's channel id is already the registered composite id;
@@ -149,178 +189,1153 @@ function behaviorFingerprint(b) {
149
189
  return typeof b === 'string' ? b : JSON.stringify(b);
150
190
  }
151
191
  // ---------------------------------------------------------------------------
152
- // Config validation
192
+ // Reading rules
153
193
  // ---------------------------------------------------------------------------
154
- function validateConfig(raw) {
155
- if (!raw || typeof raw !== 'object') {
156
- throw new Error('gate.json must be a JSON object');
194
+ // A rule is read as written when it can be, and every problem it has is named.
195
+ // gate.json and the host's starting rules are what a deployment already runs,
196
+ // so a rule with a problem is read the way earlier releases read it, and no
197
+ // event that woke the agent there goes quiet: a behavior the gate doesn't know
198
+ // is read as "always"; a field of the wrong type, one slip from one the gate
199
+ // reads there, or one the gate reads at another level of the rule (a match's
200
+ // "tagsAny" beside "name"), as left out, its problem naming where it's read;
201
+ // and a number out of range the way the starting rules ran it, but for a
202
+ // debounce, read at its nearest bound, which can send a batch later or sooner
203
+ // but drops no event in it, and a refill interval of 0, read as refilling at
204
+ // once. A rule is left out only where earlier releases refused it. One that
205
+ // skips or defers is read past a problem too, holding back what they held back:
206
+ // left out, the events it held back would go on to the rules after it, where a
207
+ // rate limit, a sample or a debounce could spend them and then hold back an
208
+ // event that woke the agent before. Under a default that holds events back, a
209
+ // file that leaves out a rule that could wake the agent isn't used, since that
210
+ // would hold back what the rule was written to wake on; earlier releases didn't
211
+ // use it either. A change through addPolicy, whose caller is there to fix it,
212
+ // refuses any problem, and nothing is saved. A field name that is no slip of
213
+ // one the gate reads there, and that it reads at no other level, changes
214
+ // nothing, a slip of another level's field included: it loads, and a note names
215
+ // it with the fields its place holds. In a rule, a name holding a field's whole
216
+ // name is such a name beside that field, an extra that explains it, and a slip
217
+ // of the field where it's given in the field's place (fieldNames); at
218
+ // gate.json's top, where it can be a label of the file, it's such a name either
219
+ // way. A file earlier releases loaded loads as they read it: a slip of
220
+ // "policies", a list of rules parked under a name the gate doesn't read, a
221
+ // rule's fields at gate.json's top and a "policies" that isn't a list each do
222
+ // nothing, and a note names each, so where "policies" writes no rules the file
223
+ // has none, and under "skip" or "defer" it holds back every event, as it did. A
224
+ // "policies" holding nothing writes no rules, as one left out does. A null is a
225
+ // field left out, and so is "", with a note: earlier releases read most fields
226
+ // that way, and none narrows anything by them. Where they refused a rule or a
227
+ // file for a null or a "" (a passthrough, a behavior's kind, gate.json's
228
+ // default), it now loads without it.
229
+ const CONFIG_FIELDS = ['default', 'policies'];
230
+ const POLICY_FIELDS = ['name', 'match', 'behavior', 'resets', 'passthrough'];
231
+ const MATCH_FIELDS = [
232
+ 'scope', 'source', 'channel', 'mount', 'pathGlob', 'tagsAny', 'tagsAll', 'tagsNone', 'metadataTrue', 'filter',
233
+ ];
234
+ const FILTER_FIELDS = ['type', 'pattern'];
235
+ const BEHAVIOR_KINDS = ['debounce', 'rate_limit', 'passive_sample'];
236
+ const RATE_LIMIT_FIELDS = ['tokens', 'refillIntervalMs', 'keyBy'];
237
+ const PASSIVE_SAMPLE_FIELDS = ['every', 'keyBy'];
238
+ function isPlainObject(value) {
239
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
240
+ }
241
+ /** A value that sets nothing: null, "", an empty list, or an object holding only such values. */
242
+ function holdsNothing(value) {
243
+ return value === null || value === '' || (Array.isArray(value) && value.length === 0)
244
+ || (isPlainObject(value) && Object.values(value).every(holdsNothing));
245
+ }
246
+ /**
247
+ * Whether a failed write left its file as it was: writeFileSync reports a
248
+ * failure once it has the file open, so it may have changed it, as its write
249
+ * or its close.
250
+ */
251
+ function beforeWriting(err) {
252
+ const syscall = err.syscall;
253
+ return syscall !== 'write' && syscall !== 'close';
254
+ }
255
+ /**
256
+ * A change's refusal where gate.json has no rule list it could edit without
257
+ * writing over what it holds: a list, JSON that isn't an object, or a
258
+ * "policies" that isn't a list and holds something. `runs` is what the gate
259
+ * runs meanwhile (rulesRun).
260
+ */
261
+ function noListRefusal(noList, runs) {
262
+ return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
263
+ + `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list holding its `
264
+ + 'rules, a change is refused the same way.';
265
+ }
266
+ /**
267
+ * A change's refusal where gate.json is there but couldn't be used as a file
268
+ * a change edits: it couldn't be read, or it isn't JSON (`notJson`). Nothing is
269
+ * written over it, so what it holds, often an operator's unfinished edit, stays
270
+ * as it is. `runs` is what the gate runs meanwhile (rulesRun).
271
+ */
272
+ function unusableRefusal(unusable, runs) {
273
+ if (!unusable.notJson) {
274
+ return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written over it. `
275
+ + `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json readable; until then, a change is `
276
+ + 'refused the same way.';
277
+ }
278
+ return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without writing `
279
+ + `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change is `
280
+ + 'refused the same way.';
281
+ }
282
+ /**
283
+ * A change's refusal at a start that didn't use gate.json, which has no rules
284
+ * of its own (start-without): a change puts the rules gate.json then holds in
285
+ * force, so it would take out of force what the start runs in its place.
286
+ * `runs` is what the gate runs meanwhile (rulesRun); gate_status's entries
287
+ * (notUsedClause, describeStart) say which rules keep gate.json out of use
288
+ * and each fix.
289
+ */
290
+ function notUsedRefusal(runs) {
291
+ return "gate.json isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in "
292
+ + `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
293
+ + 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
294
+ + 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
295
+ }
296
+ /**
297
+ * A value as a message quotes it: its JSON, cut short when long, never inside a
298
+ * surrogate pair, since the message reaches the agent's context.
299
+ */
300
+ export function shownValue(value) {
301
+ if (value === undefined)
302
+ return 'missing';
303
+ // JSON writes NaN and the infinities as null.
304
+ if (typeof value === 'number' && !Number.isFinite(value))
305
+ return String(value);
306
+ let json;
307
+ try {
308
+ json = JSON.stringify(value) ?? String(value);
157
309
  }
158
- const obj = raw;
159
- const defaultBehavior = obj.default ?? 'always';
160
- if (defaultBehavior !== 'always' && defaultBehavior !== 'defer' && defaultBehavior !== 'skip') {
161
- throw new Error(`gate.json "default" must be "always", "defer", or "skip", got: ${defaultBehavior}`);
310
+ catch {
311
+ json = String(value);
162
312
  }
163
- const policies = [];
164
- if (Array.isArray(obj.policies)) {
165
- for (const p of obj.policies) {
166
- policies.push(validatePolicy(p));
313
+ return json.length > 80 ? `${safeSlice(json, 0, 77)}...` : json;
314
+ }
315
+ /** Entries of a list as a message quotes them. */
316
+ function shownValues(values) {
317
+ return values.map(shownValue).join(', ');
318
+ }
319
+ /** `a`, `a and b`, `a, b and c` (or "or"). */
320
+ function joinedList(items, joiner = 'and') {
321
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
322
+ }
323
+ /** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
324
+ function quotedList(names, joiner = 'and') {
325
+ return joinedList(names.map((n) => `"${n}"`), joiner);
326
+ }
327
+ /** 1st, 2nd, 3rd, 4th, 11th, 21st. */
328
+ function ordinal(n) {
329
+ const tens = n % 100;
330
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th');
331
+ return `${n}${suffix}`;
332
+ }
333
+ /** A message part as one sentence, for joining several. */
334
+ function sentence(text) {
335
+ return /[.?!]$/.test(text) ? text : `${text}.`;
336
+ }
337
+ /** How a note speaks of what it checks: a rule's fields, or a tool's parameters. */
338
+ const RULE_FIELDS_TERMS = { unread: "which the gate doesn't read", listed: 'Its fields are' };
339
+ const TOOL_PARAMETERS_TERMS = { unread: "which it doesn't take", listed: 'Its parameters are' };
340
+ /**
341
+ * An object's field names that the gate doesn't read in its place: slips of
342
+ * one it does, and the rest. A name is a slip of each field isNearMiss finds
343
+ * it close to and, where `held`, of each whose whole name it holds, ignoring
344
+ * case, that the object doesn't give: sent in that field's place, it's the
345
+ * field under another name (`matchTags` with no `match`), where beside it,
346
+ * it's an extra. The object is one withoutEmpty read, so a field given as
347
+ * null or "" isn't given.
348
+ */
349
+ function fieldNames(obj, fields, held = true) {
350
+ const slips = [];
351
+ const unread = [];
352
+ for (const key of Object.keys(obj)) {
353
+ if (obj[key] === undefined || fields.includes(key))
354
+ continue;
355
+ // Every field the gate reads has four characters or more, so holding one
356
+ // needs no length of its own here. A field of two characters or fewer
357
+ // would be held by names that have nothing to do with it ("valid" holds
358
+ // "id"), and holding it would need the three characters isNearMiss asks
359
+ // of a part.
360
+ const holds = (field) => key.toLowerCase().includes(field.toLowerCase());
361
+ const near = fields.filter((field) => isNearMiss(key, field) || (held && !Object.hasOwn(obj, field) && holds(field)));
362
+ if (near.length === 0) {
363
+ unread.push(key);
364
+ continue;
167
365
  }
366
+ // closestNames keeps every name decided on above, in its order.
367
+ slips.push({ key, meant: closestNames(key, near, near.length) });
168
368
  }
169
- return { policies, default: defaultBehavior };
369
+ return { slips, unread };
370
+ }
371
+ /** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
372
+ function slipShown(slip) {
373
+ return `"${slip.key}" (did you mean ${quotedList(slip.meant, 'or')}?)`;
170
374
  }
171
- function validatePolicy(raw) {
172
- if (!raw || typeof raw !== 'object') {
173
- throw new Error('Each gate policy must be an object');
375
+ /**
376
+ * An object's names that do nothing in its place, split into those that are
377
+ * fields read elsewhere, grouped by where (`homesOf`), and the rest.
378
+ */
379
+ function placedElsewhere(unread, homesOf) {
380
+ const misplaced = [];
381
+ const rest = [];
382
+ for (const key of unread) {
383
+ const homes = homesOf(key);
384
+ if (homes.length === 0) {
385
+ rest.push(key);
386
+ continue;
387
+ }
388
+ const same = misplaced.find((m) => m.homes.join() === homes.join());
389
+ if (same)
390
+ same.keys.push(key);
391
+ else
392
+ misplaced.push({ keys: [key], homes });
174
393
  }
175
- const obj = raw;
176
- if (typeof obj.name !== 'string' || !obj.name) {
177
- throw new Error('Gate policy must have a "name" string');
394
+ return { misplaced, rest };
395
+ }
396
+ /** `"tagsAny", a field of "match"`; `"tagsAny" and "source", fields of "match"` */
397
+ function misplacedShown(m) {
398
+ return `${quotedList(m.keys)}, ${m.keys.length === 1 ? 'a field' : 'fields'} of ${joinedList(m.homes)}`;
399
+ }
400
+ /**
401
+ * The levels of a gate.json the gate reads fields at, each as a message names
402
+ * it, `file` naming the file itself.
403
+ */
404
+ function fieldLevels(file) {
405
+ return [
406
+ { name: file, fields: CONFIG_FIELDS },
407
+ { name: 'a rule', fields: POLICY_FIELDS },
408
+ { name: '"match"', fields: MATCH_FIELDS },
409
+ { name: '"match.filter"', fields: FILTER_FIELDS },
410
+ { name: '"behavior"', fields: BEHAVIOR_KINDS },
411
+ { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
412
+ { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
413
+ ];
414
+ }
415
+ /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
416
+ function homesOf(file, here) {
417
+ return (key) => fieldLevels(file)
418
+ .filter((level) => level.fields !== here && level.fields.includes(key))
419
+ .map((level) => level.name);
420
+ }
421
+ /** Where each field of an object schema's nested objects is taken: `tagsAny` in "match". */
422
+ function nestedFields(properties, path = '') {
423
+ const homes = new Map();
424
+ const add = (field, where) => homes.set(field, [...(homes.get(field) ?? []), where]);
425
+ for (const [name, schema] of Object.entries(properties)) {
426
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties))
427
+ continue;
428
+ const where = path ? `${path}.${name}` : name;
429
+ for (const field of Object.keys(schema.properties))
430
+ add(field, `"${where}"`);
431
+ for (const [field, inner] of nestedFields(schema.properties, where))
432
+ for (const at of inner)
433
+ add(field, at);
434
+ }
435
+ return homes;
436
+ }
437
+ /** A note naming the fields an object has that do nothing in its place, with the fields that do. */
438
+ function noteUnread(where, unread, fields, notes, terms = RULE_FIELDS_TERMS) {
439
+ if (unread.length === 0)
440
+ return;
441
+ notes.push(`${where} has ${quotedList(unread)}, ${terms.unread}, so ${unread.length === 1 ? 'it does' : 'they do'} `
442
+ + `nothing. ${terms.listed} ${quotedList(fields)}.`);
443
+ }
444
+ /**
445
+ * A tool's input, its parameters given by its schema's `properties`, read as a
446
+ * rule's fields are: a null or "" parameter is left out (a note names each "",
447
+ * with what leaving it out does where `means` says), a slip of a parameter
448
+ * (fieldNames) is refused, naming the one meant, and so is a field of one of
449
+ * its object parameters given on its own, naming that parameter; any other
450
+ * name is noted, or named in that refusal. Returns the input without what was
451
+ * left out.
452
+ */
453
+ export function checkToolInput(tool, input, properties, notes, means = {}) {
454
+ const params = Object.keys(properties);
455
+ const given = withoutEmpty(tool, input, notes, means);
456
+ const { slips, unread } = fieldNames(given, params);
457
+ const homes = nestedFields(properties);
458
+ const { misplaced, rest } = placedElsewhere(unread, (key) => homes.get(key) ?? []);
459
+ if (slips.length > 0 || misplaced.length > 0) {
460
+ // Every name it doesn't take is named, so the call that follows the refusal doesn't count on one.
461
+ const has = [
462
+ ...(slips.length > 0 ? [`${slips.map(slipShown).join(' and ')}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
463
+ ...misplaced.map((m) => `${misplacedShown(m)}, ${TOOL_PARAMETERS_TERMS.unread} here`),
464
+ ...(rest.length > 0 ? [`${quotedList(rest)}, ${TOOL_PARAMETERS_TERMS.unread}`] : []),
465
+ ];
466
+ // Where a field has one home, show it there, as the call would give it. A
467
+ // value shownValue cuts short isn't one the call could give, so then the
468
+ // parameter is named alone.
469
+ const moves = misplaced.filter((m) => m.homes.length === 1).map((m) => {
470
+ const it = m.keys.length === 1 ? 'it' : 'them';
471
+ const shown = m.keys.map((key) => [key, shownValue(given[key])]);
472
+ return shown.every(([key, value]) => value === JSON.stringify(given[key]))
473
+ ? `Put ${it} in ${m.homes[0]}: {${shown.map(([key, value]) => `${JSON.stringify(key)}: ${value}`).join(', ')}}.`
474
+ : `Put ${it} in ${m.homes[0]}.`;
475
+ });
476
+ throw new Error(`${tool} has ${has.join(', and ')}, so nothing was saved. ${moves.map((move) => `${move} `).join('')}`
477
+ + `${TOOL_PARAMETERS_TERMS.listed} ${quotedList(params)}.`);
178
478
  }
179
- // Validate behavior
180
- let behavior;
181
- if (obj.behavior === 'always' || obj.behavior === 'defer' || obj.behavior === 'skip') {
182
- behavior = obj.behavior;
479
+ noteUnread(tool, rest, params, notes, TOOL_PARAMETERS_TERMS);
480
+ return given;
481
+ }
482
+ /**
483
+ * An object's fields as the gate reads them: a null or "" is a field left out,
484
+ * and a note names each "", with what leaving it out does where that's more
485
+ * than setting no constraint (`means`, by field). Every other field stays an
486
+ * own field of the copy, "__proto__" included, which JSON gives as one: set by
487
+ * assignment, it would make its value the copy's prototype, whose fields the
488
+ * gate would then read as the object's own.
489
+ */
490
+ function withoutEmpty(where, obj, notes, means = {}) {
491
+ const kept = [];
492
+ const empty = [];
493
+ for (const [key, value] of Object.entries(obj)) {
494
+ if (value === null || value === undefined)
495
+ continue;
496
+ if (value === '')
497
+ empty.push(key);
498
+ else
499
+ kept.push([key, value]);
500
+ }
501
+ const out = Object.fromEntries(kept);
502
+ if (empty.length > 0) {
503
+ const meant = empty.flatMap((key) => {
504
+ // An own field only: a name such as "constructor" would find Object.prototype's.
505
+ const what = Object.hasOwn(means, key) ? means[key] : undefined;
506
+ return what ? [what] : [];
507
+ });
508
+ notes.push(`${where} has ${quotedList(empty)} as "", so ${empty.length === 1 ? "it's" : "they're"} left out`
509
+ + `${meant.length > 0 ? `: ${meant.join(', and ')}` : ''}.`);
183
510
  }
184
- else if (obj.behavior && typeof obj.behavior === 'object') {
185
- const b = obj.behavior;
186
- if ('debounce' in b) {
187
- if (typeof b.debounce !== 'number' || b.debounce <= 0) {
188
- throw new Error(`Policy "${obj.name}": debounce must be a positive number`);
189
- }
190
- if (b.debounce < MIN_DEBOUNCE_MS) {
191
- throw new Error(`Policy "${obj.name}": debounce must be >= ${MIN_DEBOUNCE_MS}ms, got ${b.debounce}`);
192
- }
193
- if (b.debounce > MAX_DEBOUNCE_MS) {
194
- throw new Error(`Policy "${obj.name}": debounce must be <= ${MAX_DEBOUNCE_MS}ms, got ${b.debounce}`);
195
- }
196
- behavior = { debounce: b.debounce };
511
+ return out;
512
+ }
513
+ /** What leaving out a rule's field does, where that's more than setting no constraint. */
514
+ const RULE_LEFT_OUT = { behavior: 'a rule without one has the behavior "always"', match: 'a rule without one matches every event' };
515
+ /** The same for gate.json's own fields, under the default it then has. */
516
+ function configLeftOut(defaultBehavior) {
517
+ return {
518
+ default: 'its default is "always"',
519
+ policies: defaultBehavior === 'skip' || defaultBehavior === 'defer'
520
+ ? `it has no rules, so under its default, "${defaultBehavior}", every event is held back`
521
+ : 'it has no rules, so every event gets the default, "always"',
522
+ };
523
+ }
524
+ /**
525
+ * A rule's notes, ending with what a match that narrows nothing does. A
526
+ * refusal says it of the rule read without what's wrong in its match, as
527
+ * earlier releases read it, since that's why what's wrong there matters.
528
+ */
529
+ function notesOf(reading, refused = false) {
530
+ if (!reading.matchesAll)
531
+ return reading.notes;
532
+ return [...reading.notes, refused
533
+ ? "Read without what's wrong in its match, as earlier releases read it, the rule would match every event."
534
+ : `${reading.where}: nothing left in match narrows the rule, so it matches every event.`];
535
+ }
536
+ /** How a slip is read: as a field left out, which does nothing. */
537
+ const SLIP_READ = 'so it does nothing';
538
+ /** How a field of the wrong type is read: as one left out. */
539
+ const DROPPED = 'so the rule is read without it';
540
+ /**
541
+ * An object's field names, at `level` of the rule (such as "match"): a slip of
542
+ * a field is a problem, read as the field left out, and so is a field the gate
543
+ * reads at another level, named with where that is (`misplacedReadOf` says
544
+ * how the rule then reads, where that's more than the field doing nothing);
545
+ * any other name the gate doesn't read is noted.
546
+ */
547
+ function readFieldNames(r, level, obj, fields, readOf = () => SLIP_READ, misplacedReadOf = () => undefined) {
548
+ const { slips, unread } = fieldNames(obj, fields);
549
+ const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
550
+ noteUnread(level ? `${r.where}: ${level}` : r.where, rest, fields, r.notes);
551
+ for (const slip of slips) {
552
+ r.problems.push({ text: `${level ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
553
+ }
554
+ for (const m of misplaced) {
555
+ r.problems.push({
556
+ text: `${level ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
557
+ read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
558
+ });
559
+ }
560
+ }
561
+ /** "so that entry is dropped", "so those entries are dropped". */
562
+ function droppedEntries(count) {
563
+ return count === 1 ? 'so that entry is dropped' : 'so those entries are dropped';
564
+ }
565
+ /**
566
+ * A rule, read as written, or past each problem it has. `unnamed` names a rule
567
+ * without a name in messages, and `file` the file it's in.
568
+ */
569
+ function readRule(raw, unnamed, file = 'gate.json') {
570
+ if (!isPlainObject(raw)) {
571
+ return {
572
+ where: unnamed,
573
+ problems: [{ text: `it's ${shownValue(raw)}, where a rule is an object, such as {"name": "my-rule", "behavior": "always"}` }],
574
+ notes: [],
575
+ quiet: false,
576
+ };
577
+ }
578
+ const name = nameOf(raw);
579
+ const r = { where: name ? `Policy "${name}"` : unnamed, file, problems: [], notes: [] };
580
+ const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
581
+ // Field names first, so a slip of one is named before what its absence does.
582
+ readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
583
+ if (slip.meant.includes('behavior') && obj.behavior === undefined)
584
+ return 'so the rule\'s behavior is "always"';
585
+ if (slip.meant.includes('match') && obj.match === undefined)
586
+ return 'so the rule matches every event';
587
+ return SLIP_READ;
588
+ }, (m) => {
589
+ const of = (fields) => m.keys.some((key) => fields.includes(key));
590
+ if (obj.match === undefined && (of(MATCH_FIELDS) || of(FILTER_FIELDS)))
591
+ return 'so the rule matches every event';
592
+ if (obj.behavior === undefined && (of(BEHAVIOR_KINDS) || of(RATE_LIMIT_FIELDS) || of(PASSIVE_SAMPLE_FIELDS))) {
593
+ return 'so the rule\'s behavior is "always"';
594
+ }
595
+ return undefined;
596
+ });
597
+ if (!name) {
598
+ r.problems.push({
599
+ text: obj.name === undefined ? 'it has no "name"' : `name is ${shownValue(obj.name)}, where a non-empty string is needed`,
600
+ });
601
+ }
602
+ const { behavior, kind } = readBehavior(r, obj.behavior);
603
+ const match = readMatch(r, obj.match);
604
+ const resets = readResets(r, obj.resets);
605
+ const passthrough = readPassthrough(r, obj.passthrough, behavior, kind);
606
+ const reading = {
607
+ where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
608
+ };
609
+ if (name && behavior && r.problems.every((p) => p.read !== undefined)) {
610
+ const policy = { name, match, behavior };
611
+ if (resets)
612
+ policy.resets = resets;
613
+ if (passthrough)
614
+ policy.passthrough = true;
615
+ reading.policy = policy;
616
+ if (r.matchesAll)
617
+ reading.matchesAll = true;
618
+ }
619
+ return reading;
620
+ }
621
+ const BEHAVIORS_NEEDED = '"always", "defer" or "skip", or an object holding one of "debounce", "rate_limit" and "passive_sample"';
622
+ /**
623
+ * A rule's behavior: "always" when left out; one of the three names; or an
624
+ * object holding one of the timing kinds. Anything else but a list or an
625
+ * object was read as "always", a rule that wakes on every event it matches,
626
+ * and is read so; a list, or an object holding none of the kinds, was
627
+ * refused, and the rule is left out. Of
628
+ * two kinds or more, "debounce", then "rate_limit", then "passive_sample" was
629
+ * the one read, whatever their order, and is read so. Each other kind is read
630
+ * on its own as well, so that a refusal names its problems too, and a load
631
+ * reads past them, since it isn't read either way. `kind` is the behavior's
632
+ * kind even where it can't be read, for passthrough's check.
633
+ */
634
+ function readBehavior(r, value) {
635
+ if (value === undefined)
636
+ return { behavior: 'always', kind: 'always' };
637
+ if (value === 'always' || value === 'defer' || value === 'skip')
638
+ return { behavior: value, kind: value };
639
+ if (!isPlainObject(value)) {
640
+ if (Array.isArray(value)) {
641
+ r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed` });
642
+ return {};
643
+ }
644
+ r.problems.push({ text: `behavior is ${shownValue(value)}, where ${BEHAVIORS_NEEDED} is needed`, read: 'so it\'s read as "always"' });
645
+ return { behavior: 'always', kind: 'always' };
646
+ }
647
+ const b = withoutEmpty(`${r.where}: behavior`, value, r.notes);
648
+ readFieldNames(r, 'behavior', b, BEHAVIOR_KINDS);
649
+ const kinds = BEHAVIOR_KINDS.filter((k) => b[k] !== undefined);
650
+ const kind = kinds[0];
651
+ if (kind === undefined) {
652
+ r.problems.push({ text: 'behavior holds none of "debounce", "rate_limit" and "passive_sample"' });
653
+ return {};
654
+ }
655
+ if (kinds.length > 1) {
656
+ r.problems.push({ text: `behavior holds ${quotedList(kinds)}, and a rule has one behavior`, read: `so only its "${kind}" is read` });
657
+ }
658
+ const behavior = READ_KIND[kind](r, b[kind]);
659
+ for (const other of kinds.slice(1)) {
660
+ const unread = { where: r.where, file: r.file, problems: [], notes: [] };
661
+ READ_KIND[other](unread, b[other]);
662
+ for (const p of unread.problems)
663
+ r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
664
+ }
665
+ return { behavior, kind };
666
+ }
667
+ /**
668
+ * A debounce: a number of milliseconds. One out of the gate's range is read
669
+ * at its nearest bound. Earlier releases refused it in gate.json and ran it
670
+ * as written in the starting rules, where one over 2,147,483,647 ms ran after
671
+ * 1 ms, the most a timer takes; read at the bound, a batch goes out later or
672
+ * sooner than it did there, and every event in it is still delivered.
673
+ */
674
+ function readDebounce(r, ms) {
675
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
676
+ r.problems.push({ text: `behavior.debounce is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
677
+ return undefined;
678
+ }
679
+ if (ms < MIN_DEBOUNCE_MS) {
680
+ r.problems.push({
681
+ text: `behavior.debounce is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
682
+ });
683
+ return { debounce: MIN_DEBOUNCE_MS };
684
+ }
685
+ if (ms > MAX_DEBOUNCE_MS) {
686
+ r.problems.push({
687
+ text: `behavior.debounce is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
688
+ });
689
+ return { debounce: MAX_DEBOUNCE_MS };
690
+ }
691
+ return { debounce: ms };
692
+ }
693
+ /**
694
+ * A rate limit. A refill interval under 0 ran as a bucket that never refills,
695
+ * waking on its first tokens and then on none of what the rule matched, and
696
+ * is read so. One of 0 refilled the bucket once and then never, its clock left
697
+ * at NaN; it's read as refilling at once, every time, so the rule limits
698
+ * nothing, which holds back nothing it woke the agent on (applyRateLimit). A
699
+ * rule whose tokens aren't a positive number is left out, as earlier releases'
700
+ * check refused it. Tokens that are a number not over 0, which ran unchecked
701
+ * from the starting rules, woke the agent on nothing the rule matched, so that
702
+ * rule is quiet (noTokens): leaving it out holds back nothing it was written
703
+ * to wake on, though what it matches goes on to the rules after it, as for any
704
+ * rule left out.
705
+ */
706
+ function readRateLimit(r, value) {
707
+ if (!isPlainObject(value)) {
708
+ r.problems.push({
709
+ text: `behavior.rate_limit is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
710
+ });
711
+ return undefined;
712
+ }
713
+ const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
714
+ readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
715
+ const tokens = conf.tokens;
716
+ const refill = conf.refillIntervalMs;
717
+ const tokensUsable = typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0;
718
+ if (typeof tokens === 'number' && !(tokens > 0))
719
+ r.noTokens = true;
720
+ if (!tokensUsable) {
721
+ r.problems.push({ text: `behavior.rate_limit.tokens is ${shownValue(tokens)}, where a positive number is needed` });
722
+ }
723
+ const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
724
+ if (!refillUsable) {
725
+ r.problems.push({
726
+ text: `behavior.rate_limit.refillIntervalMs is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
727
+ });
728
+ }
729
+ else if (refill <= 0) {
730
+ r.problems.push({
731
+ text: `behavior.rate_limit.refillIntervalMs is ${refill}, where a positive number of milliseconds is needed`,
732
+ read: refill === 0
733
+ ? 'so its tokens refill at once, and it limits nothing it matches'
734
+ : "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
735
+ });
736
+ }
737
+ const keyBy = readKeyBy(r, 'rate_limit', conf.keyBy);
738
+ if (!tokensUsable || !refillUsable)
739
+ return undefined;
740
+ const out = { tokens, refillIntervalMs: refill };
741
+ if (keyBy !== undefined)
742
+ out.keyBy = keyBy;
743
+ return { rate_limit: out };
744
+ }
745
+ /**
746
+ * A sample. A step at or under 0 sampled every event, as 1 does, and a
747
+ * fraction its next whole number, since the count was compared with it, so
748
+ * each is read that way.
749
+ */
750
+ function readPassiveSample(r, value) {
751
+ if (!isPlainObject(value)) {
752
+ r.problems.push({ text: `behavior.passive_sample is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
753
+ return undefined;
754
+ }
755
+ const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
756
+ readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
757
+ const given = conf.every;
758
+ let every;
759
+ if (typeof given !== 'number' || !Number.isFinite(given)) {
760
+ r.problems.push({ text: `behavior.passive_sample.every is ${shownValue(given)}, where a whole number of 1 or more is needed` });
761
+ }
762
+ else if (Number.isInteger(given) && given > 0) {
763
+ every = given;
764
+ }
765
+ else {
766
+ every = given <= 0 ? 1 : Math.ceil(given);
767
+ r.problems.push({
768
+ text: `behavior.passive_sample.every is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
769
+ read: `so it's read as ${every}`,
770
+ });
771
+ }
772
+ const keyBy = readKeyBy(r, 'passive_sample', conf.keyBy);
773
+ if (every === undefined)
774
+ return undefined;
775
+ const out = { every };
776
+ if (keyBy !== undefined)
777
+ out.keyBy = keyBy;
778
+ return { passive_sample: out };
779
+ }
780
+ /** How each kind of behavior object is read. */
781
+ const READ_KIND = {
782
+ debounce: readDebounce,
783
+ rate_limit: readRateLimit,
784
+ passive_sample: readPassiveSample,
785
+ };
786
+ /** A timing kind's keyBy: a field name. One of another type was read as none. */
787
+ function readKeyBy(r, kind, value) {
788
+ if (value === undefined)
789
+ return undefined;
790
+ if (typeof value === 'string')
791
+ return value;
792
+ r.problems.push({
793
+ text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
794
+ read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
795
+ });
796
+ return undefined;
797
+ }
798
+ /**
799
+ * A rule's match: every field optional, and one left out matches anything. A
800
+ * field of the wrong type, or an entry of one in a list, was dropped, which
801
+ * widened the rule, and is read so; an empty entry in a list, dropped, but for
802
+ * one in `scope`, which was kept and narrows the rule, since no event's type is
803
+ * "". A match that isn't an object matched every event, and so does one left
804
+ * with nothing that narrows it, which readRule notes, for a rule it can read,
805
+ * where something in the match was named: dropped, left out or unread.
806
+ */
807
+ function readMatch(r, value) {
808
+ const match = {};
809
+ if (value === undefined)
810
+ return match;
811
+ if (!isPlainObject(value)) {
812
+ r.problems.push({
813
+ text: `match is ${shownValue(value)}, where an object is needed, such as {"source": "discord"}`,
814
+ read: 'so the rule matches every event',
815
+ });
816
+ return match;
817
+ }
818
+ const named = r.problems.length + r.notes.length;
819
+ const m = withoutEmpty(`${r.where}: match`, value, r.notes);
820
+ readFieldNames(r, 'match', m, MATCH_FIELDS);
821
+ if (m.scope !== undefined) {
822
+ if (!Array.isArray(m.scope)) {
823
+ r.problems.push({
824
+ text: `match.scope is ${shownValue(m.scope)}, where a list of event types is needed, such as ["mcpl:channel-incoming"]`,
825
+ read: 'so the rule matches every type of event',
826
+ });
197
827
  }
198
- else if ('rate_limit' in b) {
199
- const rl = b.rate_limit;
200
- if (!rl || typeof rl !== 'object') {
201
- throw new Error(`Policy "${obj.name}": rate_limit must be an object`);
828
+ else {
829
+ const types = m.scope.filter((s) => typeof s === 'string');
830
+ const dropped = m.scope.filter((s) => typeof s !== 'string');
831
+ if (dropped.length > 0) {
832
+ r.problems.push({
833
+ text: `match.scope holds ${shownValues(dropped)}, which ${dropped.length === 1 ? "isn't an event type" : "aren't event types"}`,
834
+ read: types.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule matches every type of event`,
835
+ });
202
836
  }
203
- const conf = rl;
204
- if (typeof conf.tokens !== 'number' || !Number.isFinite(conf.tokens) || conf.tokens <= 0) {
205
- throw new Error(`Policy "${obj.name}": rate_limit.tokens must be a positive number`);
837
+ if (types.includes('')) {
838
+ r.problems.push({
839
+ text: 'match.scope holds "", and no event\'s type is ""',
840
+ read: types.every((t) => t === '') ? 'so the rule matches no event' : 'so that entry matches no event',
841
+ narrows: true,
842
+ });
206
843
  }
207
- if (typeof conf.refillIntervalMs !== 'number' || !Number.isFinite(conf.refillIntervalMs) || conf.refillIntervalMs <= 0) {
208
- throw new Error(`Policy "${obj.name}": rate_limit.refillIntervalMs must be a positive number`);
844
+ match.scope = types;
845
+ }
846
+ }
847
+ for (const [field, example] of [
848
+ ['source', '"discord"'], ['channel', '"discord:*:12345"'], ['mount', '"project"'], ['pathGlob', '"notes/*.md"'],
849
+ ]) {
850
+ const given = m[field];
851
+ if (given === undefined)
852
+ continue;
853
+ if (typeof given === 'string')
854
+ match[field] = given;
855
+ else
856
+ r.problems.push({ text: `match.${field} is ${shownValue(given)}, where a string is needed, such as ${example}`, read: DROPPED });
857
+ }
858
+ for (const [field, example] of [
859
+ ['metadataTrue', '["mentioned"]'], ['tagsAny', '["chat:ambient"]'], ['tagsAll', '["chat:ambient"]'], ['tagsNone', '["chat:ambient"]'],
860
+ ]) {
861
+ const given = m[field];
862
+ if (given === undefined)
863
+ continue;
864
+ if (!Array.isArray(given)) {
865
+ r.problems.push({
866
+ text: `match.${field} is ${shownValue(given)}, where a list of non-empty strings is needed, such as ${example}`, read: DROPPED,
867
+ });
868
+ continue;
869
+ }
870
+ const kept = given.filter((s) => typeof s === 'string' && s.length > 0);
871
+ const dropped = given.filter((s) => typeof s !== 'string' || s.length === 0);
872
+ if (dropped.length > 0) {
873
+ r.problems.push({
874
+ text: `match.${field} holds ${shownValues(dropped)}, where each entry is a non-empty string`,
875
+ read: kept.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and with none left the list narrows nothing`,
876
+ });
877
+ }
878
+ if (kept.length > 0)
879
+ match[field] = kept;
880
+ }
881
+ if (m.filter !== undefined) {
882
+ if (!isPlainObject(m.filter)) {
883
+ r.problems.push({
884
+ text: `match.filter is ${shownValue(m.filter)}, where an object is needed, such as {"type": "text", "pattern": "release"}`,
885
+ read: DROPPED,
886
+ });
887
+ }
888
+ else {
889
+ // An empty pattern matches every event's content, so a filter with one
890
+ // narrows nothing, as before, and it's left out, its other problems named.
891
+ const emptyPattern = m.filter.pattern === '';
892
+ const f = withoutEmpty(`${r.where}: match.filter`, emptyPattern ? { ...m.filter, pattern: undefined } : m.filter, r.notes);
893
+ readFieldNames(r, 'match.filter', f, FILTER_FIELDS);
894
+ const without = 'so the rule is read without its filter';
895
+ const type = f.type === 'text' || f.type === 'regex' ? f.type : undefined;
896
+ if (type === undefined) {
897
+ r.problems.push({ text: `match.filter.type is ${shownValue(f.type)}, where "text" or "regex" is needed`, read: without });
209
898
  }
210
- const out = {
211
- tokens: conf.tokens,
212
- refillIntervalMs: conf.refillIntervalMs,
213
- };
214
- if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
215
- out.keyBy = conf.keyBy;
899
+ if (emptyPattern) {
900
+ r.notes.push(`${r.where}: match.filter's pattern is "", so the filter narrows nothing, and it's left out.`);
216
901
  }
217
- behavior = { rate_limit: out };
218
- }
219
- else if ('passive_sample' in b) {
220
- const ps = b.passive_sample;
221
- if (!ps || typeof ps !== 'object') {
222
- throw new Error(`Policy "${obj.name}": passive_sample must be an object`);
902
+ else if (typeof f.pattern !== 'string') {
903
+ r.problems.push({
904
+ text: `match.filter.pattern is ${shownValue(f.pattern)}, where the text or regular expression to look for is needed`,
905
+ read: without,
906
+ });
223
907
  }
224
- const conf = ps;
225
- if (typeof conf.every !== 'number' || !Number.isInteger(conf.every) || conf.every <= 0) {
226
- throw new Error(`Policy "${obj.name}": passive_sample.every must be a positive integer`);
908
+ else if (type === 'text') {
909
+ match.filter = { type, pattern: f.pattern };
227
910
  }
228
- const out = { every: conf.every };
229
- if (typeof conf.keyBy === 'string' && conf.keyBy.length > 0) {
230
- out.keyBy = conf.keyBy;
911
+ else if (type === 'regex') {
912
+ try {
913
+ new RegExp(f.pattern);
914
+ match.filter = { type, pattern: f.pattern };
915
+ }
916
+ catch (e) {
917
+ r.problems.push({ text: `match.filter.pattern isn't a regular expression the gate can read (${errorText(e)})` });
918
+ }
231
919
  }
232
- behavior = { passive_sample: out };
233
- }
234
- else {
235
- throw new Error(`Policy "${obj.name}": unknown behavior — expected always | defer | skip | { debounce } | { rate_limit } | { passive_sample }`);
236
920
  }
237
921
  }
238
- else {
239
- behavior = 'always';
922
+ // Left with nothing that narrows it once something in it was named, it
923
+ // matches every event. Empty lists and nulls alone always meant no
924
+ // constraint, and name nothing.
925
+ const narrows = Object.entries(match).some(([field, v]) => !(field === 'scope' && Array.isArray(v) && v.length === 0));
926
+ if (!narrows && r.problems.length + r.notes.length > named)
927
+ r.matchesAll = true;
928
+ return match;
929
+ }
930
+ /** A rule's resets: names of rules whose state it clears when it fires. An entry that isn't a name was dropped. */
931
+ function readResets(r, value) {
932
+ if (value === undefined)
933
+ return undefined;
934
+ if (!Array.isArray(value)) {
935
+ r.problems.push({
936
+ text: `resets is ${shownValue(value)}, where a list of rule names is needed, such as ["other-rule"]`,
937
+ read: 'so the rule resets no other',
938
+ });
939
+ return undefined;
240
940
  }
241
- // Validate match (lenient — missing fields mean "match all")
242
- const match = {};
243
- if (obj.match && typeof obj.match === 'object') {
244
- const m = obj.match;
245
- if (Array.isArray(m.scope))
246
- match.scope = m.scope.filter(s => typeof s === 'string');
247
- if (typeof m.source === 'string')
248
- match.source = m.source;
249
- if (typeof m.channel === 'string')
250
- match.channel = m.channel;
251
- if (typeof m.mount === 'string')
252
- match.mount = m.mount;
253
- if (typeof m.pathGlob === 'string')
254
- match.pathGlob = m.pathGlob;
255
- if (Array.isArray(m.metadataTrue)) {
256
- const fields = m.metadataTrue.filter((s) => typeof s === 'string' && s.length > 0);
257
- if (fields.length > 0)
258
- match.metadataTrue = fields;
259
- }
260
- const tagList = (v) => {
261
- if (!Array.isArray(v))
262
- return undefined;
263
- const out = v.filter((s) => typeof s === 'string' && s.length > 0);
264
- return out.length > 0 ? out : undefined;
941
+ // Unknown names are ignored at runtime, so reload races don't blow up.
942
+ const names = value.filter((s) => typeof s === 'string' && s.length > 0);
943
+ const dropped = value.filter((s) => typeof s !== 'string' || s.length === 0);
944
+ if (dropped.length > 0) {
945
+ r.problems.push({
946
+ text: `resets holds ${shownValues(dropped)}, where each entry is a rule's name`,
947
+ read: names.length > 0 ? droppedEntries(dropped.length) : `${droppedEntries(dropped.length)}, and the rule resets no other`,
948
+ });
949
+ }
950
+ return names.length > 0 ? names : undefined;
951
+ }
952
+ /**
953
+ * A rule's passthrough. It's meaningful only on the counting behaviors, where
954
+ * "matched but didn't fire" is a distinct outcome that can fall through. On
955
+ * always, defer, skip or a debounce it would be a no-op at best and a
956
+ * misleading promise at worst, so it's refused rather than ignored.
957
+ */
958
+ function readPassthrough(r, value, behavior, kind) {
959
+ if (value === undefined || value === false)
960
+ return false;
961
+ if (value !== true) {
962
+ r.problems.push({ text: `passthrough is ${shownValue(value)}, where true or false is needed` });
963
+ return false;
964
+ }
965
+ if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample')
966
+ return true;
967
+ r.problems.push({
968
+ text: `passthrough is only valid with rate_limit or passive_sample behaviors — for "${behavior ? formatBehavior(behavior) : kind}" `
969
+ + 'there is no non-firing match to fall through on',
970
+ });
971
+ return false;
972
+ }
973
+ /**
974
+ * A rule with no problem is in force as written. One with a problem is read
975
+ * past it, the way earlier releases read it, so it wakes the agent wherever
976
+ * they did and holds back what they held back, a rule that skips or defers
977
+ * included. It's left out only where they refused it.
978
+ */
979
+ function ruleOutcome(reading) {
980
+ if (reading.problems.length === 0)
981
+ return 'as-written';
982
+ return reading.policy === undefined ? 'left-out' : 'read';
983
+ }
984
+ /** What a load or a start says of a rule: how it's read past its problems, or that it's left out, and its notes. */
985
+ function readingSentences(reading, outcome) {
986
+ const said = [];
987
+ if (outcome === 'read') {
988
+ // Read past a problem that can widen it, a rule that holds events back holds back what it now matches.
989
+ const behavior = reading.policy?.behavior;
990
+ const holds = (behavior === 'skip' || behavior === 'defer') && reading.problems.some((p) => !p.narrows)
991
+ ? ` Read so, it ${behavior === 'skip' ? 'skips' : 'defers'} every event it now matches, as earlier releases did.`
992
+ : '';
993
+ said.push(`${reading.where}: ${reading.problems.map((p) => `${p.text}, ${p.read}`).join('; ')}.${holds}`);
994
+ }
995
+ else if (outcome === 'left-out') {
996
+ said.push(`${reading.where} is left out: ${reading.problems.map((p) => p.text).join('; ')}.`);
997
+ }
998
+ return [...said, ...notesOf(reading)];
999
+ }
1000
+ /** addPolicy's refusal of a rule with problems: every one of them, and its notes. */
1001
+ function refusalOf(reading) {
1002
+ return [`${reading.where} wasn't saved: ${reading.problems.map((p) => p.text).join('; ')}.`, ...notesOf(reading, true)].join(' ');
1003
+ }
1004
+ /** A rule's name as written, when it has a usable one. */
1005
+ function nameOf(rule) {
1006
+ return isPlainObject(rule) && typeof rule.name === 'string' && rule.name ? rule.name : undefined;
1007
+ }
1008
+ /** What a load of gate.json says, from what its reading says of it: nothing, when that's nothing. */
1009
+ function loadNote(sentences) {
1010
+ return sentences.length > 0 ? `gate.json loaded. ${sentences.join(' ')}` : null;
1011
+ }
1012
+ /** The names of the rules a gate.json holds as written: none, for one with no rule list. */
1013
+ function heldNames(listed) {
1014
+ return new Set('noList' in listed ? [] : listed.rules.map(nameOf).filter((name) => name !== undefined));
1015
+ }
1016
+ /**
1017
+ * gate.json, or the host's starting rules, read (`file` names which in
1018
+ * messages). A file earlier releases loaded loads as they read it, with a
1019
+ * note naming what they didn't read: a list is read as no rules under the
1020
+ * default "always", and a slip of "policies", a name the gate doesn't read
1021
+ * (a list of rules parked under one included), a field of a rule at the
1022
+ * file's top and a "policies" that isn't a list each as nothing, so where
1023
+ * "policies" writes no rules the file has none, and its default decides every
1024
+ * event. A "policies" holding nothing, an empty list or one that isn't a
1025
+ * list, writes no rules, as one left out does. Earlier releases refused it
1026
+ * whole where it isn't an object, or its default isn't one of the three, and
1027
+ * it isn't used. Each rule is read on its own. Under a default that holds
1028
+ * events back, a rule left out that could wake the agent would hold back what
1029
+ * it was written to wake on, so the file isn't used, as earlier releases,
1030
+ * which refused such a rule and so the file, didn't use it. `start` is what a
1031
+ * later start ran over a gate.json with no rules of its own, for its note: the
1032
+ * host's starting rules beside one with no rule list, or, where gate.json
1033
+ * isn't used at that start, the clause saying why and what runs instead
1034
+ * (notUsedClause), which the sentence saying it has no rules carries.
1035
+ */
1036
+ function readConfig(raw, file, start) {
1037
+ const File = file === 'gate.json' ? file : 'The starting config';
1038
+ if (Array.isArray(raw)) {
1039
+ return {
1040
+ config: { policies: [], default: 'always' },
1041
+ rules: [],
1042
+ sentences: [
1043
+ `${File} is a list, where an object is needed, such as {"policies": [{"name": "my-rule", "behavior": "always"}]}, `
1044
+ + (start === 'beside'
1045
+ ? 'so none of it is read, and the gate runs the host\'s starting rules beside it, under the default, "always".'
1046
+ : 'so none of it is read, and every event gets the default, "always".'),
1047
+ ],
1048
+ listed: { noList: `${File} is a list` },
265
1049
  };
266
- const tagsAny = tagList(m.tagsAny);
267
- if (tagsAny)
268
- match.tagsAny = tagsAny;
269
- const tagsAll = tagList(m.tagsAll);
270
- if (tagsAll)
271
- match.tagsAll = tagsAll;
272
- const tagsNone = tagList(m.tagsNone);
273
- if (tagsNone)
274
- match.tagsNone = tagsNone;
275
- if (m.filter && typeof m.filter === 'object') {
276
- const f = m.filter;
277
- if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
278
- match.filter = { type: f.type, pattern: f.pattern };
279
- if (f.type === 'regex') {
280
- try {
281
- new RegExp(f.pattern);
282
- }
283
- catch (e) {
284
- throw new Error(`Policy "${obj.name}": invalid regex pattern: ${e}`);
285
- }
286
- }
287
- }
288
- }
289
1050
  }
290
- // Validate resets (optional list of policy names; unknown names are ignored
291
- // at runtime so reload races don't blow up).
292
- let resets;
293
- if (Array.isArray(obj.resets)) {
294
- const names = obj.resets.filter((s) => typeof s === 'string' && s.length > 0);
295
- if (names.length > 0)
296
- resets = names;
297
- }
298
- // Validate passthrough — only meaningful on the counting behaviors, where
299
- // "matched but didn't fire" is a distinct outcome that can fall through.
300
- // On always/defer/skip/debounce the flag would be a no-op at best and a
301
- // misleading promise at worst, so reject it loudly instead of ignoring it.
302
- let passthrough;
303
- if (obj.passthrough !== undefined) {
304
- if (typeof obj.passthrough !== 'boolean') {
305
- throw new Error(`Policy "${obj.name}": passthrough must be a boolean`);
306
- }
307
- if (obj.passthrough) {
308
- const counting = typeof behavior === 'object'
309
- && ('rate_limit' in behavior || 'passive_sample' in behavior);
310
- if (!counting) {
311
- throw new Error(`Policy "${obj.name}": passthrough is only valid with rate_limit or `
312
- + `passive_sample behaviors — for "${formatBehavior(behavior)}" there is `
313
- + 'no non-firing match to fall through on');
314
- }
315
- passthrough = true;
1051
+ if (!isPlainObject(raw))
1052
+ return { refused: `${file} must be a JSON object`, listed: { noList: `${File} is ${shownValue(raw)}` } };
1053
+ const sentences = [];
1054
+ // At a start that doesn't use it, what having no rules does is that.
1055
+ const notUsedWhy = typeof start === 'object' ? start.notUsed : undefined;
1056
+ const notUsed = notUsedWhy === undefined ? undefined : `it has no rules, so ${notUsedWhy}`;
1057
+ const obj = withoutEmpty(file, raw, sentences, { ...configLeftOut(raw.default), ...(notUsed ? { policies: notUsed } : {}) });
1058
+ // Here a name holding "default" or "policies" isn't a slip of it: at the
1059
+ // top, such a name can be a label of the file ("policiesUpdated").
1060
+ const names = fieldNames(obj, CONFIG_FIELDS, false);
1061
+ const { misplaced, rest } = placedElsewhere(names.unread, homesOf(file, CONFIG_FIELDS));
1062
+ noteUnread(file, rest, CONFIG_FIELDS, sentences);
1063
+ for (const m of misplaced) {
1064
+ sentences.push(`${file} has ${misplacedShown(m)}, which the gate doesn't read here, so ${m.keys.length === 1 ? 'it does' : 'they do'} nothing.`);
1065
+ }
1066
+ // A "policies" holding nothing writes no rules, as one left out does.
1067
+ const noPolicies = obj.policies === undefined || holdsNothing(obj.policies);
1068
+ // What a change edits, used or not: its own fields and rule list as written
1069
+ // (a list left out, null or "", or one holding nothing, is an empty one),
1070
+ // beside whatever else it holds; or, where "policies" isn't a list and holds
1071
+ // something, what it is instead, since a list there would write over it.
1072
+ const listed = noPolicies || Array.isArray(obj.policies)
1073
+ ? { top: raw, rules: Array.isArray(obj.policies) ? obj.policies : [] }
1074
+ : { noList: `${File}'s "policies" is ${shownValue(obj.policies)}` };
1075
+ const given = obj.default;
1076
+ if (given !== undefined && given !== 'always' && given !== 'defer' && given !== 'skip') {
1077
+ return { refused: `${file} "default" must be "always", "defer", or "skip", got: ${shownValue(given)}`, listed };
1078
+ }
1079
+ const defaultBehavior = given ?? 'always';
1080
+ // Beside a start that runs the host's starting rules, since it has no list to add them to, they're what its default
1081
+ // meets; at one that doesn't use it, what runs in its place is.
1082
+ const noRules = start === 'beside'
1083
+ ? `it has no rules, so the gate runs the host's starting rules beside it, under its default, "${defaultBehavior}"`
1084
+ : notUsed ?? configLeftOut(defaultBehavior).policies;
1085
+ for (const slip of names.slips) {
1086
+ let read = SLIP_READ;
1087
+ // Where "policies" writes no rules, what a slip of it holds leaves the file with none.
1088
+ if (slip.meant.includes('policies') && noPolicies)
1089
+ read = `and ${noRules}`;
1090
+ else if (slip.meant.includes('default') && obj.default === undefined)
1091
+ read = 'so its default is "always"';
1092
+ sentences.push(`${File} has ${slipShown(slip)}, which the gate doesn't read, ${read}.`);
1093
+ }
1094
+ const policies = Array.isArray(obj.policies) ? obj.policies : [];
1095
+ if (!Array.isArray(obj.policies) && obj.policies !== undefined) {
1096
+ sentences.push(`${File}'s "policies" is ${shownValue(obj.policies)}, where a list of rules is needed, such as `
1097
+ + `[{"name": "my-rule", "behavior": "always"}], and ${noRules}.`);
1098
+ }
1099
+ const rules = policies.map((rule, i) => {
1100
+ const reading = readRule(rule, `The ${ordinal(i + 1)} policy`, file);
1101
+ return { raw: rule, reading, outcome: ruleOutcome(reading) };
1102
+ });
1103
+ if (defaultBehavior !== 'always') {
1104
+ const silenced = rules.filter((rule) => rule.outcome === 'left-out' && !rule.reading.quiet);
1105
+ if (silenced.length > 0) {
1106
+ const one = silenced.length === 1;
1107
+ return {
1108
+ refused: [
1109
+ `${file} leaves out ${one ? 'a rule' : 'rules'} that could wake the agent, and under its default, "${defaultBehavior}", `
1110
+ + `what ${one ? 'it was' : 'they were'} written to wake it on would be held back.`,
1111
+ ...silenced.flatMap((rule) => readingSentences(rule.reading, rule.outcome)),
1112
+ ].join(' '),
1113
+ listed,
1114
+ silenced: silenced.map((rule) => nameOf(rule.raw)),
1115
+ };
316
1116
  }
317
1117
  }
318
- const policy = { name: obj.name, match, behavior };
319
- if (resets)
320
- policy.resets = resets;
321
- if (passthrough)
322
- policy.passthrough = passthrough;
323
- return policy;
1118
+ for (const rule of rules)
1119
+ sentences.push(...readingSentences(rule.reading, rule.outcome));
1120
+ // A list with no rules, or none the gate reads, where no sentence above said it has none.
1121
+ if (notUsed !== undefined && !sentences.some((said) => said.includes(notUsed))) {
1122
+ sentences.push(`${File} has no rules${rules.length > 0 ? ' but ones the gate leaves out' : ''}, so ${notUsedWhy}.`);
1123
+ }
1124
+ return {
1125
+ config: {
1126
+ policies: rules.filter((rule) => rule.outcome !== 'left-out').map((rule) => rule.reading.policy),
1127
+ default: defaultBehavior,
1128
+ },
1129
+ rules,
1130
+ sentences,
1131
+ listed,
1132
+ };
1133
+ }
1134
+ function readStartingRules(raw) {
1135
+ const read = readConfig(raw, 'the starting config');
1136
+ if ('refused' in read)
1137
+ return { refusedWhole: read.refused, rules: [], sentences: [] };
1138
+ return {
1139
+ config: read.config,
1140
+ given: raw,
1141
+ rules: read.rules.map((rule) => ({ name: nameOf(rule.raw), raw: rule.raw, policy: rule.reading.policy, quiet: rule.reading.quiet })),
1142
+ sentences: read.sentences,
1143
+ };
1144
+ }
1145
+ /**
1146
+ * The recipe the gate last applied (lastRecipePath), as written, or null when
1147
+ * it has none on record. It's read as the host's starting rules are, so a
1148
+ * recipe recorded as given reads back as the start read it. A record that
1149
+ * exists and isn't one the gate wrote (it can't be read, isn't JSON, is
1150
+ * refused as the starting rules are, or names a rule twice) gives null with
1151
+ * the reason, which quotes nothing from the file but a rule's name: a start
1152
+ * then removes and redefines nothing, as the first does, and says why.
1153
+ */
1154
+ function readLastRecipe(path) {
1155
+ if (!existsSync(path))
1156
+ return { recipe: null };
1157
+ let raw;
1158
+ try {
1159
+ raw = parseGateJson(readFileSync(path, 'utf-8'));
1160
+ }
1161
+ catch (err) {
1162
+ const code = err.code;
1163
+ return { recipe: null, problem: code ? `reading it failed with ${code}` : "it isn't JSON" };
1164
+ }
1165
+ const read = readStartingRules(raw);
1166
+ if (read.refusedWhole !== undefined)
1167
+ return { recipe: null, problem: "it isn't read as the starting rules are" };
1168
+ const rules = read.rules.map((rule) => rule.raw);
1169
+ const twice = duplicateName(rules);
1170
+ if (twice !== undefined)
1171
+ return { recipe: null, problem: `it names the rule "${twice}" twice` };
1172
+ return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
1173
+ }
1174
+ /**
1175
+ * A rule's meaning, for the start's merge: the rule as the gate reads it,
1176
+ * where it reads, and as written where it doesn't. So a rule `wake_add_rule`
1177
+ * wrote as read agrees with the same rule written more briefly.
1178
+ */
1179
+ function ruleMeaning(rule) {
1180
+ const reading = readRule(rule, 'A policy');
1181
+ return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
1182
+ }
1183
+ /**
1184
+ * What gate_status says about the start, which stays until the next one: the
1185
+ * host's starting rules that couldn't be used and what the start did instead,
1186
+ * how it read them past problems, and rules a reconcile couldn't add. Worded
1187
+ * about the start, so later writes and reloads
1188
+ * leave it true; configSource says what's in force now. `held` is the names
1189
+ * of the rules gate.json holds as written, when it loaded.
1190
+ */
1191
+ function describeStart(starting, write, held) {
1192
+ const parts = [];
1193
+ if (starting.refusedWhole !== undefined) {
1194
+ // gate.json's rules: as loaded, or as read before a write of the start's
1195
+ // own left it unable to load; or none, where gate.json wasn't used either.
1196
+ const instead = write.kind === 'start-without'
1197
+ ? `gate.json, which has no rules the gate reads, wasn't used either: under its default, "${write.default}", it would hold back every `
1198
+ + 'event, so the gate started with no rules, as a start without gate.json does. Giving gate.json rules under '
1199
+ + '"policies", or fixing the starting rules, puts rules in force (a fix to the starting rules, at the next start).'
1200
+ : held !== null || write.kind === 'reconcile-failed'
1201
+ ? "The gate started with gate.json's rules."
1202
+ : 'The gate started with no rules, as on a host without starting rules.';
1203
+ parts.push(`At start, the host's starting rules weren't used: ${sentence(starting.refusedWhole)} ${instead}`);
1204
+ }
1205
+ if (starting.sentences.length > 0)
1206
+ parts.push(`In the host's starting rules, ${starting.sentences.join(' ')}`);
1207
+ // A reconcile whose write failed and left gate.json loading: the rules it
1208
+ // was adding didn't reach it, unless the write landed before failing.
1209
+ if (write.kind === 'reconcile-failed' && held) {
1210
+ if (!write.names.every((name) => held.has(name))) {
1211
+ parts.push(`At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), `
1212
+ + "so they weren't put in force. "
1213
+ + (write.text !== undefined
1214
+ ? 'Whoever runs this host can fix what kept them from being written.'
1215
+ : 'A start adds them once what kept them out is fixed.'));
1216
+ }
1217
+ // The merge's other changes: removals, redefinitions, moves and the default.
1218
+ if (write.changing?.length) {
1219
+ parts.push(`At start, the recipe's changes to gate.json (${quotedList(write.changing)}) `
1220
+ + (write.atOpen
1221
+ ? `weren't saved (${write.error}), so the gate started with gate.json's rules as they were. `
1222
+ : `may not all have been saved (${write.error}): the gate started with gate.json as the write left it. `)
1223
+ + (write.text !== undefined
1224
+ ? 'Whoever runs this host can fix what kept them from being written.'
1225
+ : 'A start applies them once what kept them out is fixed.'));
1226
+ }
1227
+ }
1228
+ return parts.length > 0 ? parts.join(' ') : null;
1229
+ }
1230
+ /**
1231
+ * Why gate.json, which has no rules of its own, isn't used at a start that runs
1232
+ * as one without it (start-without), and what runs in its place: the clause
1233
+ * after "it has no rules, so" in its note.
1234
+ */
1235
+ function notUsedClause(write, starting) {
1236
+ const back = 'Giving gate.json rules under "policies"';
1237
+ const out = outOfForce(write);
1238
+ if (out) {
1239
+ return `it isn't used at this start: under its default, "${write.default}", ${out.said}, so the gate runs the starting `
1240
+ + `rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json does. ${back}, or ${out.fix}, puts it in use`
1241
+ + (out.nextStart ? ' (a fix to the starting rules, at the next start)' : '');
1242
+ }
1243
+ return `under its default, "${write.default}", it would hold back every event: it isn't used at this start, and the gate runs `
1244
+ + `no rules, as a start without gate.json does, so every event gets the default, "always". ${back}, or fixing the starting rules, puts rules in force`
1245
+ + ' (a fix to the starting rules, at the next start)';
1246
+ }
1247
+ /**
1248
+ * The starting rules a start that doesn't use gate.json couldn't put in force
1249
+ * beside it or in it, which could wake the agent, and what would put them in
1250
+ * force: null where the starting rules were refused whole.
1251
+ */
1252
+ function outOfForce(write) {
1253
+ const nameless = write.nameless ?? 0;
1254
+ const left = [...(write.leftOut ?? []).map((name) => `"${name}"`), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1255
+ const held = write.heldOut ?? [];
1256
+ const said = [];
1257
+ const fix = [];
1258
+ if (left.length > 0) {
1259
+ said.push(`the host's starting rules would leave out ${joinedList(left)}, which could wake the agent`);
1260
+ fix.push(`fixing ${joinedList(left)} in the starting rules`);
1261
+ }
1262
+ if (held.length > 0) {
1263
+ said.push(`the host's ${quotedList(held)}, which could wake the agent, would be kept out by gate.json's `
1264
+ + `${held.length === 1 ? 'rule of that name' : 'rules of those names'}, which the gate leaves out`);
1265
+ fix.push(`fixing gate.json's ${quotedList(held)}`);
1266
+ }
1267
+ // While gate.json has no rules of its own, every one of them has to be put
1268
+ // in force for it to be used, so where both kinds keep it out, both fixes
1269
+ // are needed; giving gate.json rules of its own is the other way, which the
1270
+ // entry gives first.
1271
+ // The starting rules are read at a start, so a fix to them puts gate.json in
1272
+ // use from the next one.
1273
+ return said.length > 0
1274
+ ? { said: said.join(', and '), fix: fix.length > 1 ? `both ${fix.join(', and ')}` : fix[0], nextStart: left.length > 0 }
1275
+ : null;
1276
+ }
1277
+ /**
1278
+ * What gate_status says when the start left rules in force unsaved: a first
1279
+ * start's seed that failed, a reconcile whose write left gate.json unable to
1280
+ * load (`loaded` false), or, over a gate.json with no rule list (`loaded`
1281
+ * true), the starting rules it lacks, run beside it, or, over one with no rules
1282
+ * of its own, the starting rules run under their own default where gate.json
1283
+ * isn't used. It goes once gate.json is written, or a reload applies its text
1284
+ * or, but where the start ran rules beside or in place of such a gate.json,
1285
+ * finds it holding the text in force.
1286
+ */
1287
+ function describeUnsaved(starting, write, loaded) {
1288
+ if (write.kind === 'reconcile-unsaved') {
1289
+ if (!loaded)
1290
+ return null;
1291
+ return `At start, the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}). `
1292
+ + `The gate runs them under gate.json's default, "${write.config.default ?? 'always'}", as if gate.json held them, but `
1293
+ + "they aren't saved in it: once gate.json changes and loads, what it holds is in force instead. Once gate.json has a "
1294
+ + 'list of rules under "policies", a start adds them to it, as to any gate.json with a list.';
1295
+ }
1296
+ if (write.kind === 'start-without') {
1297
+ // Starting rules refused whole put none in force, and describeStart says so.
1298
+ const out = outOfForce(write);
1299
+ if (!out)
1300
+ return null;
1301
+ return `At start, gate.json, which has no rules the gate reads, wasn't used: under its default, "${write.default}", ${out.said}. The gate `
1302
+ + `runs the starting rules under their own default, "${starting.config?.default ?? 'always'}", as a start without gate.json `
1303
+ + 'does, but they aren\'t saved in it: once gate.json changes and loads, what it holds is in force instead. Giving '
1304
+ + `gate.json rules under "policies", or ${out.fix}, puts it in use`
1305
+ + `${out.nextStart ? ' (a fix to the starting rules, at the next start)' : ''}.`;
1306
+ }
1307
+ if (loaded)
1308
+ return null;
1309
+ if (write.kind === 'seed-failed') {
1310
+ return write.atOpen
1311
+ ? `At start, gate.json couldn't be created (${write.error}), so the host's starting rules are in force but aren't saved in it. `
1312
+ + 'Whoever runs this host can fix what keeps it from being written.'
1313
+ : `At start, writing the host's starting rules to gate.json failed partway (${write.error}), so gate.json may be left `
1314
+ + "part-written. The starting rules are in force but aren't saved in it, and a restart before gate.json is fixed starts "
1315
+ + 'from them too. Whoever runs this host can fix what kept it from being written, and gate.json.';
1316
+ }
1317
+ if (write.kind === 'reconcile-failed') {
1318
+ // A write of the recipe's merge that did more than add rules, or one that only added them.
1319
+ const merging = (write.changing?.length ?? 0) > 0;
1320
+ const changes = quotedList([...write.names, ...(write.changing ?? [])]);
1321
+ const what = merging
1322
+ ? write.atOpen
1323
+ ? `the recipe's changes to gate.json (${changes}) couldn't be written (${write.error}), and gate.json couldn't be loaded after that`
1324
+ : `writing the recipe's changes to gate.json (${changes}) failed partway (${write.error}), so gate.json may be left part-written`
1325
+ : write.atOpen
1326
+ ? `the host's starting rules ${quotedList(write.names)} couldn't be added to gate.json (${write.error}), and gate.json `
1327
+ + "couldn't be loaded after that"
1328
+ : `adding the host's starting rules ${quotedList(write.names)} to gate.json failed partway (${write.error}), so gate.json `
1329
+ + 'may be left part-written';
1330
+ // What those rules hold that the gate reads past or leaves out, since
1331
+ // gate.json's own entry is now its load error.
1332
+ const read = write.sentences?.length ? `, read as a load reads them: ${write.sentences.join(' ')}` : '.';
1333
+ const runs = merging ? 'the rules that write held' : "the rules it read from gate.json and the ones it was adding";
1334
+ return `At start, ${what}. The gate runs ${runs}, which aren't saved${read} `
1335
+ + `A restart before gate.json is fixed starts from ${starting.config ? "the host's starting rules" : 'no rules'}. `
1336
+ + 'Whoever runs this host can fix what kept it from being written, and gate.json.';
1337
+ }
1338
+ return null;
324
1339
  }
325
1340
  // ---------------------------------------------------------------------------
326
1341
  // Shadow lint — prove (conservatively) when rule ordering makes a rule dead
@@ -426,6 +1441,44 @@ export function formatShadowWarning(w) {
426
1441
  return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
427
1442
  + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
428
1443
  }
1444
+ /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
1445
+ function errorText(err) {
1446
+ try {
1447
+ return err instanceof Error ? err.message : String(err);
1448
+ }
1449
+ catch {
1450
+ return 'a value that could not be printed';
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Run a caller's callback once the change it's told of is made and traced. A
1455
+ * throw from it is logged, not thrown, so the call answers as the change it
1456
+ * made: told the change had failed, an agent could make it a second time.
1457
+ */
1458
+ function afterChange(name, callback) {
1459
+ try {
1460
+ callback();
1461
+ }
1462
+ catch (err) {
1463
+ console.error(`[gate] ${name} threw once the change was made, and the change stands: ${errorText(err)}`);
1464
+ }
1465
+ }
1466
+ /** The privileged-users file's error when some entries of its list aren't user ids. */
1467
+ function skippedPrivilegedIds(skipped, loaded) {
1468
+ const one = skipped.length === 1;
1469
+ const shown = skipped.slice(0, 5).map((x) => {
1470
+ if (typeof x === 'number' && Number.isInteger(x)) {
1471
+ return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
1472
+ }
1473
+ const json = JSON.stringify(x) ?? String(x);
1474
+ return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
1475
+ });
1476
+ const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
1477
+ const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`;
1478
+ return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, `
1479
+ + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
1480
+ + `(a whole number within 2^53 - 1 of zero); ${rest}.`;
1481
+ }
429
1482
  // ---------------------------------------------------------------------------
430
1483
  // EventGate
431
1484
  // ---------------------------------------------------------------------------
@@ -435,11 +1488,37 @@ export class EventGate {
435
1488
  /** Optional agent-authored programmable gate (gate.js). */
436
1489
  gateScript;
437
1490
  compiledPolicies = [];
438
- configMtime = 0;
1491
+ /** The text of gate.json whose rules are in force, from a load or a write
1492
+ * (or the start's reconcile, whose write failed); or, while what's in force
1493
+ * is what a start ran beside gate.json or in its place (configSource not
1494
+ * "file"), gate.json's text as that start found it.
1495
+ * A reload parses the file again only when its text differs; a file that
1496
+ * holds it again after failing to load (a chmod that makes it readable,
1497
+ * an edit undone) needs nothing applied, only its error cleared. */
1498
+ configText;
1499
+ /** The notes on fields the text in force has that the gate doesn't read, if any. */
1500
+ configNote = null;
439
1501
  lastReloadCheck = 0;
440
1502
  configSource = 'default';
441
1503
  lastReloadTimestamp = null;
442
- configErrors = [];
1504
+ /** The current problem with each file the gate reads, one each, replaced
1505
+ * whenever that file is read again (gate_status `errors`). */
1506
+ configError = null;
1507
+ /** Whether gate.json's error is one that keeps what it holds out of force
1508
+ * (configUnreadable), so gate_status says what's in force instead. */
1509
+ configUnused = false;
1510
+ privilegedError = null;
1511
+ /** What the start did with the host's starting rules that it couldn't use
1512
+ * or add (describeStart), kept apart from gate.json's own error, until the
1513
+ * next start. */
1514
+ startNotice = null;
1515
+ /** The rules in force that the start couldn't save (describeUnsaved),
1516
+ * until gate.json is written or a reload applies its text. */
1517
+ startUnsaved = null;
1518
+ /** What the start's merge of the recipe couldn't do (seedOrReconcile), said with startNotice until the next start. */
1519
+ startMergeNote = null;
1520
+ /** Whether the start's unsaved rules (startUnsaved) are a merge's that did more than add rules. */
1521
+ startUnsavedMerged = false;
443
1522
  // Debounce state
444
1523
  debounceTimers = new Map();
445
1524
  // Rate-limit state — policy name → key → bucket.
@@ -490,7 +1569,9 @@ export class EventGate {
490
1569
  // privilegedUsersPath on change.
491
1570
  privilegedUsersPath;
492
1571
  privilegedUserIds = new Set();
493
- privilegedMtime = 0;
1572
+ /** The privileged-users file's text as last read, whether or not it loaded;
1573
+ * undefined when it was missing or its last read failed. */
1574
+ privilegedText;
494
1575
  lastPrivilegedCheck = 0;
495
1576
  // Dependency-injected callbacks
496
1577
  emitTrace;
@@ -498,6 +1579,8 @@ export class EventGate {
498
1579
  requestInferenceFn;
499
1580
  getAgentNamesFn;
500
1581
  resolveRouteChannelFn;
1582
+ channelShownFn;
1583
+ isNewestEntryFn;
501
1584
  /** Clock injection — keeps the new rate_limit / passive_sample paths
502
1585
  * testable without monkey-patching Date.now globally. */
503
1586
  now;
@@ -509,52 +1592,79 @@ export class EventGate {
509
1592
  this.requestInferenceFn = opts.requestInference;
510
1593
  this.getAgentNamesFn = opts.getAgentNames;
511
1594
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
1595
+ this.channelShownFn = opts.channelShown;
1596
+ this.isNewestEntryFn = opts.isNewestEntry;
512
1597
  this.now = opts.now ?? (() => Date.now());
513
1598
  this.loadPrivileged();
514
- // Seed or reconcile the on-disk config.
1599
+ // Seed or reconcile the on-disk config from the host's starting rules
1600
+ // (`initialConfig`), which are read as gate.json is, rule by rule: a rule
1601
+ // with a problem is read past it, or left out (readConfig).
515
1602
  //
516
- // First start: write `initialConfig` verbatim.
517
- // Subsequent starts: if the recipe/config source declares policies that
518
- // aren't yet in gate.json (e.g. the user edited the recipe between
519
- // sessions), append them by name. Existing policies are left alone so
520
- // user edits via `workspace--edit _config/gate.json` survive.
521
- // The `default` field is not reconciled — it's an explicit operator
522
- // choice and a recipe change shouldn't silently flip live wake
523
- // behavior for a session that's already been running.
524
- if (opts.initialConfig) {
525
- try {
526
- mkdirSync(dirname(this.configPath), { recursive: true });
527
- if (!existsSync(this.configPath)) {
528
- writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
529
- }
530
- else {
531
- const existing = this.loadConfig();
532
- if (existing) {
533
- const existingNames = new Set(existing.policies.map(p => p.name));
534
- const missing = opts.initialConfig.policies.filter(p => !existingNames.has(p.name));
535
- if (missing.length > 0) {
536
- const reconciled = {
537
- ...existing,
538
- policies: [...existing.policies, ...missing],
539
- };
540
- writeFileSync(this.configPath, JSON.stringify(reconciled, null, 2) + '\n');
541
- opts.emitTrace({
542
- type: 'gate:config-reconciled',
543
- configPath: this.configPath,
544
- addedPolicies: missing.map(p => p.name),
545
- timestamp: Date.now(),
546
- });
547
- }
548
- }
549
- }
550
- }
551
- catch (err) {
552
- const msg = err instanceof Error ? err.message : String(err);
553
- this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`);
554
- }
1603
+ // First start: write `initialConfig` as given, since gate.json is read the
1604
+ // same way, and record it as the recipe last applied, beside gate.json.
1605
+ // Later starts: merge the recipe into gate.json against that record,
1606
+ // three ways and as written (seedOrReconcile), so a rule the recipe
1607
+ // dropped, renamed, moved or redefined follows it, while a rule edited at
1608
+ // run time or by `workspace--edit _config/gate.json`, and one no recipe
1609
+ // declared, stays. `default` follows the same rule: an unedited one takes
1610
+ // the recipe's.
1611
+ const initial = opts.initialConfig;
1612
+ const starting = initial !== undefined ? readStartingRules(initial) : undefined;
1613
+ const write = starting ? this.seedOrReconcile(starting) : { kind: 'none' };
1614
+ // Load config: gate.json; without it, the starting rules as read, unless
1615
+ // none of them is used; and otherwise no rules, as a host without any has. When a
1616
+ // reconcile's own write left gate.json unable to load, the gate runs what
1617
+ // that write held, the rules it read as the recipe's merge changed them,
1618
+ // since a failed write of ours shouldn't take rules out of force. Over a gate.json
1619
+ // with no rule list to add them to, it runs gate.json's default with the
1620
+ // starting rules it lacks, as earlier releases did once they had written
1621
+ // them over it, where seedOrReconcile gives them (reconcile-unsaved). Where
1622
+ // gate.json has no rules of its own and they can't run beside it or be
1623
+ // added to it (start-without), earlier releases couldn't load what they
1624
+ // wrote and ran the starting rules from memory, so gate.json isn't used,
1625
+ // and the start runs as one without it does.
1626
+ const read = this.readConfigFile();
1627
+ if (read.state === 'loaded') {
1628
+ // Beside a gate.json with no rule list, the host's starting rules it
1629
+ // lacks, and its note says they run beside it, or, in place of one with
1630
+ // no rules of its own, those a start without it runs, and its note says
1631
+ // why.
1632
+ const beside = write.kind === 'reconcile-unsaved';
1633
+ const without = write.kind === 'start-without' ? write : null;
1634
+ this.config = beside ? write.config : without ? (starting?.config ?? { ...DEFAULT_CONFIG }) : read.config;
1635
+ this.configText = read.text;
1636
+ const start = beside ? 'beside' : without && starting ? { notUsed: notUsedClause(without, starting) } : undefined;
1637
+ const asStarted = start ? readConfig(parseGateJson(read.text), 'gate.json', start) : null;
1638
+ this.configNote = asStarted && 'sentences' in asStarted ? loadNote(asStarted.sentences) : read.note;
1639
+ this.configError = this.configNote;
1640
+ this.configSource = beside || starting?.config && without ? 'initial' : without ? 'default' : 'file';
1641
+ if (this.configNote)
1642
+ console.error(`[gate] ${this.configNote}`);
1643
+ }
1644
+ else if (write.kind === 'reconcile-failed') {
1645
+ this.config = write.config;
1646
+ // A change over the part-written gate.json is refused; once gate.json is
1647
+ // gone, this text is what a change edits, so every rule it holds keeps its name.
1648
+ this.configText = write.text;
1649
+ this.configNote = write.note ?? null;
1650
+ this.configSource = 'file';
1651
+ }
1652
+ else {
1653
+ this.config = starting?.config ?? { ...DEFAULT_CONFIG };
1654
+ this.configSource = starting?.config ? 'initial' : 'default';
1655
+ }
1656
+ // What the start did is kept apart from gate.json's load error, so the
1657
+ // load above can't clear it when it succeeds.
1658
+ if (starting) {
1659
+ const described = describeStart(starting, write, read.state === 'loaded' ? heldNames(read.listed) : null);
1660
+ this.startNotice = [described, this.startMergeNote].filter((part) => part !== null).join(' ') || null;
1661
+ this.startUnsaved = describeUnsaved(starting, write, read.state === 'loaded');
1662
+ this.startUnsavedMerged = write.kind === 'reconcile-failed' && (write.changing?.length ?? 0) > 0;
1663
+ }
1664
+ for (const said of [this.startNotice, this.startUnsaved]) {
1665
+ if (said)
1666
+ console.error(`[gate] ${said}`);
555
1667
  }
556
- // Load config
557
- this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
558
1668
  this.compiledPolicies = this.compilePolicies(this.config);
559
1669
  this.refreshShadowWarnings('startup');
560
1670
  // Programmable gate: an optional agent-authored `gate.js` next to gate.json.
@@ -564,30 +1674,382 @@ export class EventGate {
564
1674
  // =========================================================================
565
1675
  // Config loading
566
1676
  // =========================================================================
567
- loadConfig() {
568
- if (!existsSync(this.configPath)) {
569
- return null;
1677
+ /**
1678
+ * Write the starting rules to gate.json, as given, when it's absent, and
1679
+ * record them as the recipe last applied (lastRecipePath); or, when it
1680
+ * loads, merge them into its own rule list against that record, three ways
1681
+ * and as written (reconcileWithRecipe), and record them. A rule as the last
1682
+ * recipe left it follows the new one, a rule edited since keeps its edit, a
1683
+ * rule no recipe declared stays, and `default` follows the same rule. A
1684
+ * rule gate.json holds holds its name, whether the gate reads it or leaves
1685
+ * it out. gate.json is written before the record, so a stop between the two
1686
+ * leaves a state the next start reads rightly: the other order would make
1687
+ * the old recipe's rules read as edits. Nothing is recorded where gate.json
1688
+ * isn't written as the merge asks, or a name is given twice. Where
1689
+ * gate.json has no rules of its own and, under its default, one of them that
1690
+ * could wake the agent can't be put in force (left out, with no name, or kept
1691
+ * out by a rule of gate.json's of its name that the gate leaves out), or
1692
+ * they're refused whole and that default holds events back, gate.json isn't
1693
+ * used at this start, which runs as one without it does. Otherwise, where
1694
+ * gate.json has no rule list to add them to, it writes nothing, and the gate
1695
+ * runs beside it those of them it reads. Says what it did when that wasn't
1696
+ * simply done or not needed.
1697
+ */
1698
+ seedOrReconcile(starting) {
1699
+ const read = this.readConfigFile();
1700
+ if (read.state === 'absent') {
1701
+ if (!starting.config)
1702
+ return { kind: 'none' };
1703
+ try {
1704
+ mkdirSync(dirname(this.configPath), { recursive: true });
1705
+ writeFileSync(this.configPath, JSON.stringify(starting.given, null, 2) + '\n');
1706
+ }
1707
+ catch (err) {
1708
+ return { kind: 'seed-failed', error: errorText(err), atOpen: beforeWriting(err) };
1709
+ }
1710
+ const twice = duplicateName(starting.rules.map((rule) => rule.raw));
1711
+ if (twice !== undefined) {
1712
+ this.startMergeNote = `At start, the recipe names the rule "${twice}" twice, so gate.json was seeded with it as given and it `
1713
+ + "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
1714
+ + 'name each rule once.';
1715
+ }
1716
+ else {
1717
+ this.recordRecipe(lastRecipePath(this.configPath), starting.given, 'gate.json was seeded with the recipe');
1718
+ }
1719
+ return { kind: 'none' };
1720
+ }
1721
+ if (read.state !== 'loaded')
1722
+ return { kind: 'none' };
1723
+ const held = heldNames(read.listed);
1724
+ const fileDefault = read.config.default ?? 'always';
1725
+ // No rule list, an empty one, or one holding only rules the gate leaves out.
1726
+ const noRules = read.config.policies.length === 0;
1727
+ // Starting rules refused whole put none beside or in a gate.json with no
1728
+ // rules of its own, which holds events back. Earlier releases wrote them
1729
+ // over it or into it, couldn't load what they wrote, and ran them from
1730
+ // memory, so gate.json isn't used at this start either.
1731
+ if (noRules && starting.refusedWhole !== undefined && fileDefault !== 'always') {
1732
+ return { kind: 'start-without', default: fileDefault };
1733
+ }
1734
+ // Nor can a start put in force, beside or in a gate.json with no rules of
1735
+ // its own, a starting rule a load leaves out, one with no name, which it
1736
+ // can't add by name, or one whose name gate.json holds in a rule the gate
1737
+ // leaves out, which keeps the host's rule of that name out. Where, under
1738
+ // gate.json's default, one of them could wake the agent, earlier releases
1739
+ // ran the starting rules from memory: they couldn't load what they wrote
1740
+ // over gate.json or into it, or, refusing its rule, didn't load it at all.
1741
+ // So gate.json isn't used at this start, which runs as one without it does.
1742
+ if (noRules && fileDefault !== 'always') {
1743
+ const out = starting.rules.filter((rule) => !rule.quiet && (rule.policy === undefined || (rule.name !== undefined && held.has(rule.name))));
1744
+ if (out.length > 0) {
1745
+ const named = out.flatMap((rule) => (rule.name === undefined ? [] : [rule.name]));
1746
+ return {
1747
+ kind: 'start-without',
1748
+ default: fileDefault,
1749
+ leftOut: named.filter((name) => !held.has(name)),
1750
+ nameless: out.length - named.length,
1751
+ heldOut: named.filter((name) => held.has(name)),
1752
+ };
1753
+ }
570
1754
  }
1755
+ if ('noList' in read.listed) {
1756
+ const missing = starting.rules.filter((rule) => rule.name !== undefined && !held.has(rule.name));
1757
+ if (missing.length === 0)
1758
+ return { kind: 'none' };
1759
+ const names = missing.map((rule) => rule.name);
1760
+ // Adding them would write over what gate.json holds. Earlier releases
1761
+ // wrote them over it and ran them under its default, so the gate runs
1762
+ // them so, unsaved, beside it.
1763
+ const error = `${read.listed.noList}, so there's no list of rules to add them to without writing over what it holds`;
1764
+ // The ones it runs: a starting rule left out is named as the starting rules are read.
1765
+ const running = missing.flatMap((rule) => (rule.policy === undefined ? [] : [rule.policy]));
1766
+ if (running.length === 0)
1767
+ return { kind: 'reconcile-failed', names, atOpen: true, config: read.config, error };
1768
+ return { kind: 'reconcile-unsaved', names: running.map((policy) => policy.name), error, config: { policies: running, default: fileDefault } };
1769
+ }
1770
+ // Starting rules refused whole have nothing to merge or record.
1771
+ if (starting.refusedWhole !== undefined)
1772
+ return { kind: 'none' };
1773
+ // The merge, as written: gate.json's rule list and default against the
1774
+ // recipe's, three ways, with the recipe last applied.
1775
+ const recordPath = lastRecipePath(this.configPath);
1776
+ const last = readLastRecipe(recordPath);
1777
+ const given = isPlainObject(starting.given) ? starting.given : {};
1778
+ let merged;
1779
+ let appended = false;
1780
+ let appendedNote = '';
571
1781
  try {
572
- const raw = readFileSync(this.configPath, 'utf-8');
573
- const stat = statSync(this.configPath);
574
- this.configMtime = stat.mtimeMs;
575
- const config = validateConfig(parseGateJson(raw));
576
- this.configSource = 'file';
577
- this.configErrors = [];
578
- return config;
1782
+ merged = reconcileWithRecipe({ rules: read.listed.rules, default: read.listed.top.default }, { rules: starting.rules.map((rule) => rule.raw), default: given.default }, last.recipe, ruleMeaning);
579
1783
  }
580
1784
  catch (err) {
581
- const msg = err instanceof Error ? err.message : String(err);
582
- this.configErrors = [msg];
1785
+ // A name given twice: a merge by name can't say which rule was meant.
1786
+ // So the start adds the rules gate.json lacks, after its own, as
1787
+ // earlier releases did, and removes, redefines and records nothing.
1788
+ const missing = starting.rules.filter((rule) => rule.name !== undefined && !held.has(rule.name));
1789
+ const lacked = [...new Set(missing.map((rule) => rule.name))];
1790
+ appended = true;
1791
+ // What it did is said once its write is made (appendedNote), and a
1792
+ // write that fails is said as any start's is.
1793
+ const why = `At start, ${errorText(err)}, and the merge with the recipe needs one rule per name, so it didn't run`;
1794
+ const remedy = 'Once gate.json and the recipe name each rule once, a start merges the recipe.';
1795
+ this.startMergeNote = `${why}. ${remedy}`;
1796
+ appendedNote = `${why}: `
1797
+ + (lacked.length > 0
1798
+ ? `the start added the rules gate.json lacked (${quotedList(lacked)}), after its own, and removed, redefined and recorded nothing. `
1799
+ : 'gate.json is as it was, and nothing was recorded. ')
1800
+ + remedy;
1801
+ merged = {
1802
+ rules: [...read.listed.rules, ...missing.map((rule) => rule.raw)],
1803
+ default: read.listed.top.default,
1804
+ defaultChanged: false,
1805
+ changes: { ...noChanges(), added: missing.map((rule) => rule.name) },
1806
+ };
1807
+ }
1808
+ const { changes } = merged;
1809
+ if (last.problem !== undefined)
1810
+ changes.recordUnreadable = last.problem;
1811
+ const top = { ...read.listed.top };
1812
+ if (merged.defaultChanged) {
1813
+ if (merged.default === undefined)
1814
+ delete top.default;
1815
+ else
1816
+ top.default = merged.default;
1817
+ }
1818
+ const reconciled = { ...top, policies: merged.rules };
1819
+ const names = [...changes.added, ...changes.restored];
1820
+ const changing = [...changes.removed, ...changes.updated, ...changes.moved, ...(changes.defaultChange ? ['default'] : [])];
1821
+ const written = canonical(reconciled) !== canonical({ ...read.listed.top, policies: read.listed.rules });
1822
+ if (written) {
1823
+ // What the write holds, as a load reads it, for a write that fails partway.
1824
+ const holds = readConfig(reconciled, 'gate.json');
1825
+ if ('refused' in holds) {
1826
+ // A write of ours doesn't take gate.json's own rules out of force:
1827
+ // nothing is written or recorded, so it loads as it did. (With no
1828
+ // rules of its own, gate.json isn't used at this start, above.)
1829
+ const error = 'with them, gate.json would leave out a rule that could wake the agent, and under its default, '
1830
+ + `"${changes.defaultChange?.to ?? read.config.default ?? 'always'}", it wouldn't be used`;
1831
+ return { kind: 'reconcile-failed', names, changing, atOpen: true, config: read.config, error };
1832
+ }
1833
+ const text = JSON.stringify(reconciled, null, 2) + '\n';
1834
+ try {
1835
+ writeFileSync(this.configPath, text);
1836
+ }
1837
+ catch (err) {
1838
+ return {
1839
+ kind: 'reconcile-failed',
1840
+ names,
1841
+ changing,
1842
+ error: errorText(err),
1843
+ atOpen: beforeWriting(err),
1844
+ config: holds.config,
1845
+ text,
1846
+ note: loadNote(holds.sentences),
1847
+ sentences: holds.sentences,
1848
+ };
1849
+ }
1850
+ }
1851
+ if (appended)
1852
+ this.startMergeNote = appendedNote;
1853
+ // Said before the record is written, so a failed record write still
1854
+ // leaves what the start did to gate.json named. A start that only added
1855
+ // rules, since the merge couldn't run, is said in startNotice instead.
1856
+ const line = appended ? null : reconcileLine(changes);
1857
+ if (line)
1858
+ console.error(line);
1859
+ if (line || names.length > 0) {
1860
+ this.emitTrace({
1861
+ type: 'gate:config-reconciled',
1862
+ configPath: this.configPath,
1863
+ ...changes,
1864
+ // Every name the start added to gate.json, as an append's trace gave.
1865
+ addedPolicies: names,
1866
+ timestamp: Date.now(),
1867
+ });
1868
+ }
1869
+ if (!appended && (!last.recipe || canonical({ rules: last.recipe.rules, default: last.recipe.default }) !== canonical({ rules: starting.rules.map((rule) => rule.raw), default: given.default }))) {
1870
+ this.recordRecipe(recordPath, starting.given, written ? 'gate.json holds the recipe merged into it' : 'the merge left gate.json as it was');
1871
+ }
1872
+ return { kind: 'none' };
1873
+ }
1874
+ /**
1875
+ * Record the starting config, as given, as the recipe last applied, after
1876
+ * gate.json holds what the start merged (`holds` says what that is). A
1877
+ * write that fails is said with the start: the next start merges against
1878
+ * the record as the write left it.
1879
+ */
1880
+ recordRecipe(recordPath, given, holds) {
1881
+ try {
1882
+ writeFileSync(recordPath, JSON.stringify(given, null, 2) + '\n');
1883
+ }
1884
+ catch (err) {
1885
+ this.startMergeNote = `At start, ${holds}, and the gate runs it, but recording the recipe in ${basename(recordPath)} failed `
1886
+ + `(${errorText(err)}), so the next start merges against the record as this write left it. Whoever runs this host can `
1887
+ + 'fix what kept it from being written.';
1888
+ }
1889
+ }
1890
+ /**
1891
+ * gate.json as it is on disk: absent; loaded; refused, when it's JSON that
1892
+ * readConfig doesn't use; or unreadable, when it can't be read or isn't
1893
+ * JSON (`notJson`, where it could be read). A loaded file comes with its note,
1894
+ * which names what it holds that does nothing or is read past a problem, and
1895
+ * a loaded or refused one with its own fields and rule list as written, for a
1896
+ * write to edit (Listed).
1897
+ * Reading applies nothing: whoever applies a loaded config records its text
1898
+ * as `configText`. With `unlessUnchanged`, a file whose text is
1899
+ * `configText` comes back `unchanged`, unparsed, since the rules in force
1900
+ * came from it, or are what a start ran beside it or in its place.
1901
+ */
1902
+ readConfigFile(opts) {
1903
+ let raw;
1904
+ try {
1905
+ raw = readFileSync(this.configPath, 'utf-8');
1906
+ }
1907
+ catch (err) {
1908
+ if (!isMissing(err))
1909
+ return this.configUnreadable(err);
1910
+ // No file is no problem with it: the rules in force stay.
1911
+ this.configError = null;
1912
+ this.configUnused = false;
1913
+ return { state: 'absent' };
1914
+ }
1915
+ // A file holding the text in force has that text's notes as its error,
1916
+ // whatever a failed read or a look at other text since left there: beside
1917
+ // the host's starting rules, they say those run beside it.
1918
+ const inForce = raw === this.configText;
1919
+ if (opts?.unlessUnchanged && inForce) {
1920
+ this.configError = this.configNote;
1921
+ this.configUnused = false;
1922
+ return { state: 'unchanged' };
1923
+ }
1924
+ let parsed;
1925
+ try {
1926
+ parsed = parseGateJson(raw);
1927
+ }
1928
+ catch (err) {
1929
+ return { ...this.configUnreadable(err), notJson: true };
1930
+ }
1931
+ const read = readConfig(parsed, 'gate.json');
1932
+ // JSON the gate doesn't use is still the operator's file, edited as written.
1933
+ if ('refused' in read) {
1934
+ return { ...this.configUnreadable(new Error(read.refused)), state: 'refused', listed: read.listed, silenced: read.silenced };
1935
+ }
1936
+ // A field the gate doesn't read does nothing, and a rule is read past a
1937
+ // problem or left out, so the file loads, and each is named.
1938
+ const note = loadNote(read.sentences);
1939
+ this.configError = inForce ? this.configNote : note;
1940
+ this.configUnused = false;
1941
+ return { state: 'loaded', config: read.config, text: raw, note, listed: read.listed };
1942
+ }
1943
+ /**
1944
+ * gate.json's current error, replacing the last one, and traced and logged
1945
+ * when it's new, so an edit that doesn't load is in the log once.
1946
+ */
1947
+ configUnreadable(err) {
1948
+ const msg = err instanceof Error ? err.message : String(err);
1949
+ if (msg !== this.configError) {
1950
+ console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
583
1951
  this.emitTrace({
584
1952
  type: 'gate:config-error',
585
1953
  error: msg,
586
1954
  configPath: this.configPath,
587
1955
  timestamp: Date.now(),
588
1956
  });
589
- return null; // Caller keeps previous config
590
1957
  }
1958
+ this.configError = msg;
1959
+ this.configUnused = true;
1960
+ return { state: 'unreadable', error: msg };
1961
+ }
1962
+ /**
1963
+ * gate.json's error as gate_status shows it: one that keeps what the file
1964
+ * holds out of force says what's in force instead, so it reads on its own.
1965
+ */
1966
+ configErrorShown() {
1967
+ if (this.configError === null || !this.configUnused)
1968
+ return this.configError;
1969
+ return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`;
1970
+ }
1971
+ /** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
1972
+ rulesRun() {
1973
+ if (this.config.policies.length === 0)
1974
+ return `no rules: every event gets the default, "${this.config.default ?? 'always'}"`;
1975
+ // Rules from a gate.json text, with the start's unsaved: only a start
1976
+ // whose reconcile write left gate.json unable to load runs those, since a
1977
+ // write of gate.json that lands, a reload that finds it holding a text
1978
+ // that loads, or, while gate.json's rules are in force, one that finds it
1979
+ // holding the text in force, clears startUnsaved.
1980
+ return this.configSource === 'file'
1981
+ ? this.startUnsaved !== null
1982
+ ? this.startUnsavedMerged
1983
+ ? 'the rules its start was writing to gate.json'
1984
+ : 'the rules it read from gate.json at start and the ones it was adding'
1985
+ : 'the rules of the last gate.json it used'
1986
+ : "the host's starting rules";
1987
+ }
1988
+ /**
1989
+ * What a policy change edits: gate.json's own fields and rule list as
1990
+ * written, each rule as written, so a write keeps every rule it doesn't
1991
+ * change, problems and unknown fields included, whether gate.json loads or
1992
+ * is JSON the gate doesn't use. Over one it doesn't use, writeConfig writes
1993
+ * only what loads, so a change is refused, naming what keeps the file out
1994
+ * and what would bring it into force (`remedy`), unless it mends that, and
1995
+ * the file stays as its operator wrote it. When
1996
+ * gate.json is absent, the same of the text whose rules are in force, so a
1997
+ * rule it leaves out keeps its name; or the rules in force, where no text
1998
+ * is, it has no rule list, or what's in force is what a start ran beside or
1999
+ * in place of it. A change is refused (`refused`) for a gate.json that can't
2000
+ * be read or isn't JSON (unusableRefusal), whose rules can't be edited
2001
+ * without writing over what it holds, and for one with no rule list a write
2002
+ * could edit without that (a list, a "policies" that isn't one and holds
2003
+ * something, or JSON that isn't an object): so a removal or an anchor is
2004
+ * refused too, rather than looked for in rules that may not be what the
2005
+ * file holds. It's refused as well for a gate.json that holds the text a
2006
+ * start didn't use (start-without), since what the change wrote would put
2007
+ * its rules in force in place of what the start runs.
2008
+ */
2009
+ rulesForChange() {
2010
+ const read = this.readConfigFile();
2011
+ if (read.state === 'unreadable')
2012
+ return { refused: unusableRefusal(read, this.rulesRun()) };
2013
+ if (read.state === 'loaded' || read.state === 'refused') {
2014
+ if ('noList' in read.listed)
2015
+ return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
2016
+ // Rules not gate.json's in force while it holds the text in force: what a
2017
+ // start ran in place of one with no rules of its own, until it changes.
2018
+ if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
2019
+ return { refused: notUsedRefusal(this.rulesRun()) };
2020
+ }
2021
+ const edit = { top: read.listed.top, rules: [...read.listed.rules] };
2022
+ if (read.state === 'loaded')
2023
+ return edit;
2024
+ // What would bring it into force, for a change that doesn't. A change
2025
+ // finds its rule by name: a replacement takes the first rule with it,
2026
+ // and a removal every one.
2027
+ const names = [...new Set(read.silenced ?? [])];
2028
+ const only = names.length === 1 ? names[0] : undefined;
2029
+ const sharing = only === undefined ? 0 : edit.rules.filter((rule) => nameOf(rule) === only).length;
2030
+ // A removal that would leave no rules in force is refused (writeConfig),
2031
+ // so it isn't offered.
2032
+ const rest = readConfig({ ...edit.top, policies: edit.rules.filter((rule) => nameOf(rule) !== only) }, 'gate.json');
2033
+ const removes = !('refused' in rest) && rest.config.policies.length > 0;
2034
+ return {
2035
+ ...edit,
2036
+ remedy: sharing === 1
2037
+ ? `${removes ? 'Replacing or removing' : 'Replacing'} "${only}" by name would bring gate.json into force.`
2038
+ : sharing > 1
2039
+ ? `gate.json has ${sharing} rules named "${only}", and a change by that name replaces the first of them`
2040
+ + `${removes ? ' or removes them all' : ''}.`
2041
+ : 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
2042
+ + 'until then, a change is refused the same way.',
2043
+ };
2044
+ }
2045
+ // While the rules in force aren't gate.json's, configText is the text a
2046
+ // start ran them beside or in place of, so its rules aren't the ones in force.
2047
+ const text = this.configText !== undefined && this.configSource === 'file'
2048
+ ? readConfig(parseGateJson(this.configText), 'gate.json')
2049
+ : undefined;
2050
+ return text && !('refused' in text) && !('noList' in text.listed)
2051
+ ? { top: text.listed.top, rules: [...text.listed.rules] }
2052
+ : { top: { policies: [], default: this.config.default ?? 'always' }, rules: [...this.config.policies] };
591
2053
  }
592
2054
  compilePolicies(config) {
593
2055
  return config.policies.map(policy => {
@@ -619,53 +2081,70 @@ export class EventGate {
619
2081
  return compiled;
620
2082
  });
621
2083
  }
2084
+ /**
2085
+ * Clear the state of each rule `was` held in force that `next` leaves out or
2086
+ * gives another behavior: a reload calls it before it puts `next` in force,
2087
+ * a write after. Without this, a rule that came back, or whose tokens 100
2088
+ * became 5 (or keyBy switched, or every 10 became 3), would run on the
2089
+ * buckets and counters of the rule it was. A rule left out loses its stats
2090
+ * (matchCount, lastMatchTimestamp) too; a rule kept keeps them, which are
2091
+ * history, not live state, and one kept with the same behavior keeps all its
2092
+ * state, its denial and fire counts included. Rules that share a name share
2093
+ * its state, so a name keeps it only where its rules, in order, keep their
2094
+ * behaviors. Each pending debounce batch is sent last, as its timer would
2095
+ * have sent it, once every rule's state is cleared; one whose delivery throws
2096
+ * is named in the log and dropped (fireDebounce), and the rest are still
2097
+ * sent, so the write or reload that cleared it has done what it was for, its
2098
+ * caller hears that it did, and no state is left behind.
2099
+ */
2100
+ clearStateFor(was, next) {
2101
+ const behaviorsByName = (config) => {
2102
+ const byName = new Map();
2103
+ for (const p of config.policies)
2104
+ byName.set(p.name, [...(byName.get(p.name) ?? []), behaviorFingerprint(p.behavior)]);
2105
+ return new Map([...byName].map(([name, behaviors]) => [name, JSON.stringify(behaviors)]));
2106
+ };
2107
+ const before = behaviorsByName(was);
2108
+ const after = behaviorsByName(next);
2109
+ const removed = [...before.keys()].filter((name) => !after.has(name));
2110
+ for (const name of removed) {
2111
+ this.clearPolicyState(name);
2112
+ this.stats.delete(name);
2113
+ }
2114
+ const cleared = [...after.keys()].filter((name) => before.has(name) && before.get(name) !== after.get(name));
2115
+ for (const name of cleared)
2116
+ this.clearPolicyState(name);
2117
+ for (const name of [...removed, ...cleared])
2118
+ this.fireDebounce(name);
2119
+ return { removed, cleared };
2120
+ }
622
2121
  reloadIfChanged() {
623
2122
  const now = Date.now();
624
- if (now - this.lastReloadCheck < RELOAD_THROTTLE_MS)
2123
+ if (checkedRecently(now, this.lastReloadCheck))
625
2124
  return;
626
2125
  this.lastReloadCheck = now;
627
2126
  try {
628
- if (!existsSync(this.configPath))
629
- return;
630
- const stat = statSync(this.configPath);
631
- if (stat.mtimeMs === this.configMtime)
2127
+ // Missing, unchanged or unreadable: the rules in force stay.
2128
+ const read = this.readConfigFile({ unlessUnchanged: true });
2129
+ // gate.json holds the text in force, so none the start ran is unsaved,
2130
+ // unless what's in force is what the start ran beside it or in its place.
2131
+ if (read.state === 'unchanged' && this.configSource === 'file')
2132
+ this.startUnsaved = null;
2133
+ if (read.state !== 'loaded')
632
2134
  return;
633
- const newConfig = this.loadConfig();
634
- if (!newConfig)
635
- return; // Parse error — keep previous config
636
- // Capture old policies' behavior fingerprints before any state
637
- // mutation — needed to detect kept policies whose parameters changed.
638
- const oldBehaviorByName = new Map();
639
- for (const p of this.config.policies) {
640
- oldBehaviorByName.set(p.name, behaviorFingerprint(p.behavior));
641
- }
642
- // Clean up state for REMOVED policies (gone from new config entirely).
643
- const newPolicyNames = new Set(newConfig.policies.map(p => p.name));
644
- const removedPolicies = [];
645
- for (const oldName of oldBehaviorByName.keys()) {
646
- if (!newPolicyNames.has(oldName)) {
647
- this.clearPolicyState(oldName, { deliverPendingDebounce: true });
648
- this.stats.delete(oldName);
649
- removedPolicies.push(oldName);
650
- }
651
- }
652
- // Clean up state for KEPT policies whose behavior parameters changed.
653
- // Without this, a tokens 100→5 edit (or keyBy switch, or every 10→3,
654
- // etc.) wouldn't take effect until existing buckets/counters drained.
655
- // Stats are preserved — matchCount is historical, not live state.
656
- const stateClearedPolicies = [];
657
- for (const newPolicy of newConfig.policies) {
658
- const oldHash = oldBehaviorByName.get(newPolicy.name);
659
- if (oldHash === undefined)
660
- continue; // new policy, no state to clear
661
- const newHash = behaviorFingerprint(newPolicy.behavior);
662
- if (oldHash !== newHash) {
663
- this.clearPolicyState(newPolicy.name, { deliverPendingDebounce: true });
664
- stateClearedPolicies.push(newPolicy.name);
665
- }
666
- }
2135
+ const newConfig = read.config;
2136
+ const { removed: removedPolicies, cleared: stateClearedPolicies } = this.clearStateFor(this.config, newConfig);
667
2137
  this.config = newConfig;
668
2138
  this.compiledPolicies = this.compilePolicies(newConfig);
2139
+ // Recorded only now that its rules are in force, so a reload that
2140
+ // throws before here is tried again at the next check.
2141
+ this.configText = read.text;
2142
+ this.configNote = read.note;
2143
+ this.configSource = 'file';
2144
+ if (read.note)
2145
+ console.error(`[gate] ${read.note}`);
2146
+ // The rules in force are now gate.json's, so none the start ran is unsaved.
2147
+ this.startUnsaved = null;
669
2148
  this.lastReloadTimestamp = now;
670
2149
  this.refreshShadowWarnings('reload');
671
2150
  this.emitTrace({
@@ -678,7 +2157,7 @@ export class EventGate {
678
2157
  });
679
2158
  }
680
2159
  catch {
681
- // Ignore stat errors
2160
+ // The rules in force stay, and the next check tries again.
682
2161
  }
683
2162
  }
684
2163
  // =========================================================================
@@ -687,52 +2166,83 @@ export class EventGate {
687
2166
  /**
688
2167
  * Add or replace (upsert) a single policy in gate.json and apply it live.
689
2168
  *
690
- * The raw policy is validated with the SAME `validatePolicy` used when loading
691
- * gate.json — an invalid shape throws (nothing is written) and the caller
692
- * surfaces it as a tool error. On success the policy is persisted to gate.json
693
- * (so it survives restart and is visible to `workspace--edit _config/gate.json`)
2169
+ * The raw policy is read with the SAME `readRule` used when loading gate.json,
2170
+ * and since its caller is there to fix it, any problem it has throws, naming
2171
+ * every one (nothing is written), and the caller surfaces it as a tool error,
2172
+ * where a load reads past it (readConfig). On success the policy is persisted
2173
+ * to gate.json (so it survives restart and is visible to `workspace--edit _config/gate.json`)
694
2174
  * and the in-memory config is swapped in immediately, so the rule takes effect
695
- * without waiting for the throttled mtime reload.
2175
+ * without waiting for the throttled reload.
696
2176
  *
697
2177
  * The freshest on-disk config is re-read first, so a concurrent operator edit
698
- * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no
699
- * `position` is given (order preserved); WITH a `position` it is moved there
700
- * — so re-issuing a rule is also how you reposition it. New policies append
701
- * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING,
702
- * including addressed-message wake rules — see the shadow lint), and the
703
- * anchored forms `{before: name}` / `{after: name}` insert relative to an
704
- * existing rule, which is almost always the safer way to order a sampler
705
- * or debounce against a specific catch-all.
2178
+ * isn't clobbered, and the change edits gate.json's own rule list by name:
2179
+ * every rule it doesn't change is written back as it was written, problems and
2180
+ * fields the gate doesn't read included, and so is everything else gate.json
2181
+ * holds, such as a list of rules parked under another name, beside which a
2182
+ * change adds a "policies" list where there's none (rulesForChange). Over a
2183
+ * gate.json the gate doesn't use, only a change after which it loads is
2184
+ * written, such as one replacing or removing the rule that keeps it out, and
2185
+ * that leaves it rules in force, since it holds events back by default; any
2186
+ * other is refused, naming what does, and the file stays as written. When
2187
+ * gate.json is absent, the change edits the text whose rules are in force the
2188
+ * same way, or the rules in force where there's none. A gate.json that can't
2189
+ * be read or isn't JSON is refused (unusableRefusal), and stays as it is. A policy with the
2190
+ * same `name` is replaced IN PLACE when no `position` is given (order
2191
+ * preserved); WITH a `position` it is moved there — so re-issuing a rule is
2192
+ * also how you reposition it. New policies append by default; `'prepend'` puts
2193
+ * one at the very front (ahead of EVERYTHING, including addressed-message wake
2194
+ * rules — see the shadow lint), and the anchored forms `{before: name}` /
2195
+ * `{after: name}` insert relative to an existing rule, which is almost always
2196
+ * the safer way to order a sampler or debounce against a specific catch-all.
2197
+ * Fields of the policy the gate reads nowhere, and that aren't a slip of one
2198
+ * it does, are left out of what's written, and named to `onIgnoredFields` once
2199
+ * the change is written and traced. A throw from the callback is logged,
2200
+ * and the change stands (afterChange). A field it reads at another level is a
2201
+ * problem, like a slip.
2202
+ *
2203
+ * Throws, writing nothing, for an invalid policy, a placement it can't make,
2204
+ * a gate.json that can't be read or isn't JSON, one with no rule list it
2205
+ * could edit without writing over what it holds, one a start didn't use,
2206
+ * which runs as one without it does, while it's unchanged, or one the gate
2207
+ * doesn't use that the change doesn't mend or would leave with no rules in
2208
+ * force; and throws when the write fails, which may leave gate.json
2209
+ * part-written.
706
2210
  */
707
2211
  addPolicy(rawPolicy, options) {
708
- const policy = validatePolicy(rawPolicy);
709
- const current = this.loadConfig() ?? this.config;
710
- const policies = [...current.policies];
711
- const idx = policies.findIndex((p) => p.name === policy.name);
2212
+ const reading = readRule(rawPolicy, 'A policy');
2213
+ if (reading.problems.length > 0 || !reading.policy)
2214
+ throw new Error(refusalOf(reading));
2215
+ const policy = reading.policy;
2216
+ const ignored = notesOf(reading);
2217
+ const edit = this.rulesForChange();
2218
+ if ('refused' in edit)
2219
+ throw new Error(edit.refused);
2220
+ const { top, rules, remedy } = edit;
2221
+ const idx = rules.findIndex((rule) => nameOf(rule) === policy.name);
712
2222
  const position = options?.position;
713
2223
  if (idx >= 0 && position === undefined) {
714
- policies[idx] = policy; // replace in place, order preserved
2224
+ rules[idx] = policy; // replace in place, order preserved
715
2225
  }
716
2226
  else {
717
2227
  if (idx >= 0)
718
- policies.splice(idx, 1); // repositioning a known rule
2228
+ rules.splice(idx, 1); // repositioning a known rule
719
2229
  if (position === 'prepend') {
720
- policies.unshift(policy);
2230
+ rules.unshift(policy);
721
2231
  }
722
2232
  else if (position !== undefined && typeof position === 'object') {
723
2233
  const anchorName = 'before' in position ? position.before : position.after;
724
- const anchorIdx = policies.findIndex((p) => p.name === anchorName);
2234
+ const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
725
2235
  if (anchorIdx < 0) {
726
2236
  throw new Error(`addPolicy: no policy named "${anchorName}" to anchor on — `
727
- + `have: [${policies.map((p) => p.name).join(', ')}]`);
2237
+ + `have: [${rules.map(nameOf).filter((name) => name !== undefined).join(', ')}]`);
728
2238
  }
729
- policies.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
2239
+ rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
730
2240
  }
731
2241
  else {
732
- policies.push(policy);
2242
+ rules.push(policy);
733
2243
  }
734
2244
  }
735
- this.writeConfig({ ...current, policies });
2245
+ this.writeConfig(top, rules, remedy);
736
2246
  this.emitTrace({
737
2247
  type: 'gate:policy-added',
738
2248
  configPath: this.configPath,
@@ -740,25 +2250,47 @@ export class EventGate {
740
2250
  replaced: idx >= 0,
741
2251
  timestamp: Date.now(),
742
2252
  });
2253
+ if (ignored.length > 0) {
2254
+ // A caller that doesn't take the notes still gets them said, in the log.
2255
+ const onIgnoredFields = options?.onIgnoredFields;
2256
+ if (onIgnoredFields)
2257
+ afterChange('onIgnoredFields', () => onIgnoredFields(ignored));
2258
+ else
2259
+ console.error(`[gate] ${ignored.join(' ')}`);
2260
+ }
743
2261
  return policy;
744
2262
  }
745
2263
  /**
746
2264
  * Remove a policy by name from gate.json and clear its live state. Any pending
747
- * debounce batch is delivered first (matching reloadIfChanged's removal path,
748
- * so buffered events aren't silently dropped). Returns false if no policy of
749
- * that name exists (nothing written). Freshest on-disk config is re-read first
750
- * (see addPolicy).
2265
+ * debounce batch is sent once the removal is written and in force, as its
2266
+ * timer would have sent it, so buffered events aren't silently dropped
2267
+ * (clearStateFor). Freshest on-disk config is re-read first, and every other
2268
+ * rule is written back as it was written (see addPolicy). A name that isn't
2269
+ * a non-empty string names no rule, so it returns false, writing nothing,
2270
+ * whatever gate.json holds. Otherwise it throws, writing nothing, for a
2271
+ * gate.json that can't be read or isn't JSON, one with no rule list it could
2272
+ * edit, or one a start didn't use, as addPolicy does, and returns false,
2273
+ * writing nothing, if no policy of that name exists, gate.json's own rules
2274
+ * included, so one the gate leaves out can be removed by name too. For a name
2275
+ * it holds, it throws, writing nothing, for a gate.json the gate doesn't use
2276
+ * that the removal doesn't mend or would leave with no rules in force, and
2277
+ * throws when the write fails, which may
2278
+ * leave gate.json part-written; the rules in force then stay as they were,
2279
+ * with their state.
751
2280
  */
752
2281
  removePolicy(name) {
753
- const current = this.loadConfig() ?? this.config;
754
- if (!current.policies.some((p) => p.name === name)) {
2282
+ // nameOf gives undefined for a rule with no name, so a call from JavaScript
2283
+ // with no name would otherwise remove every such rule.
2284
+ if (typeof name !== 'string' || name === '')
755
2285
  return false;
756
- }
757
- // Deliver any pending debounce batch, then drop timers/buckets/counters.
758
- this.clearPolicyState(name, { deliverPendingDebounce: true });
759
- this.stats.delete(name);
760
- const policies = current.policies.filter((p) => p.name !== name);
761
- this.writeConfig({ ...current, policies });
2286
+ const edit = this.rulesForChange();
2287
+ if ('refused' in edit)
2288
+ throw new Error(edit.refused);
2289
+ if (!edit.rules.some((rule) => nameOf(rule) === name))
2290
+ return false;
2291
+ // The write clears the rule's state once it has succeeded (writeConfig),
2292
+ // so a refusal or a failed write leaves the rule in force with its state.
2293
+ this.writeConfig(edit.top, edit.rules.filter((rule) => nameOf(rule) !== name), edit.remedy);
762
2294
  this.emitTrace({
763
2295
  type: 'gate:policy-removed',
764
2296
  configPath: this.configPath,
@@ -768,34 +2300,74 @@ export class EventGate {
768
2300
  return true;
769
2301
  }
770
2302
  /**
771
- * List the current policy names (freshest on-disk view). Cheap helper for
2303
+ * List the current policy names: those of a newer gate.json that loads (the
2304
+ * freshest on-disk view), and otherwise the rules in force, which beside a
2305
+ * gate.json with no rule list are the host's starting rules. Cheap helper for
772
2306
  * tools/UX that want to show what's active without the full status payload.
773
2307
  */
774
2308
  listPolicyNames() {
775
- const current = this.loadConfig() ?? this.config;
2309
+ const read = this.readConfigFile({ unlessUnchanged: true });
2310
+ const current = read.state === 'loaded' ? read.config : this.config;
776
2311
  return current.policies.map((p) => p.name);
777
2312
  }
778
2313
  /**
779
- * Persist a config to gate.json and apply it in memory together: write the
780
- * file, swap in the compiled policies, and sync `configMtime` to the new file
781
- * so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws
782
- * if the write fails (disk/permission) — callers surface it as a tool error.
2314
+ * Persist gate.json and apply it in memory together: `top`, its own fields,
2315
+ * with `rules` as its rule list. Only what loads is written: a change to a
2316
+ * gate.json the gate doesn't use, for which `remedy` is given, what would
2317
+ * bring it into force, throws, naming that and `remedy`, and writes nothing,
2318
+ * where the change doesn't mend what keeps it out, or where it would leave
2319
+ * gate.json with no rules in force: such a gate.json holds events back by
2320
+ * default, so putting it in force would hold back every event, those the
2321
+ * rules in force wake on included. The rules in force are then what the text
2322
+ * written reads as, as at a load, so what it still holds that the gate reads
2323
+ * past or leaves out stays named; the text is kept as `configText`, so
2324
+ * `reloadIfChanged` doesn't parse it again. Once the text is written and its
2325
+ * rules are in force, a rule that was in force that it leaves out or gives
2326
+ * another behavior loses its state (clearStateFor), last, since a pending
2327
+ * batch it sends can't be taken back, and a write that fails leaves the
2328
+ * rules in force with their state; a batch that can't be delivered is
2329
+ * logged, not thrown. A write that fails throws, saying whether gate.json was
2330
+ * changed: one that fails before writing leaves it as it was, and one that
2331
+ * fails after its open may leave it part-written (beforeWriting). Callers
2332
+ * surface it as a tool error.
783
2333
  */
784
- writeConfig(config) {
785
- mkdirSync(dirname(this.configPath), { recursive: true });
786
- writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
787
- this.config = config;
788
- this.compiledPolicies = this.compilePolicies(config);
2334
+ writeConfig(top, rules, remedy) {
2335
+ const text = JSON.stringify({ ...top, policies: rules }, null, 2) + '\n';
2336
+ const read = readConfig(JSON.parse(text), 'gate.json');
2337
+ if ('refused' in read) {
2338
+ throw new Error(`Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}${remedy ? ` ${remedy}` : ''}`);
2339
+ }
2340
+ // One the gate doesn't use, and that a change can mend, holds back what a
2341
+ // rule it leaves out would wake on, so its default holds events back.
2342
+ if (remedy !== undefined && read.config.policies.length === 0) {
2343
+ throw new Error('Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every '
2344
+ + `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`);
2345
+ }
2346
+ try {
2347
+ mkdirSync(dirname(this.configPath), { recursive: true });
2348
+ writeFileSync(this.configPath, text);
2349
+ }
2350
+ catch (err) {
2351
+ throw new Error(beforeWriting(err)
2352
+ ? `gate.json couldn't be written (${errorText(err)}), so nothing was saved: the rules in force and gate.json stay `
2353
+ + 'as they were. Whoever runs this host can fix what keeps it from being written.'
2354
+ : `Writing gate.json failed partway (${errorText(err)}), so the change isn't in force: the rules in force stay as `
2355
+ + 'they were. gate.json may hold the whole change, which the gate reads at its next reload; or it may hold part of it '
2356
+ + 'or nothing, which the gate can\'t read, and then they stay until gate.json is fixed. '
2357
+ + 'Whoever runs this host can fix what kept it from being written, and check what gate.json holds.', { cause: err });
2358
+ }
2359
+ const was = this.config;
2360
+ this.configText = text;
2361
+ this.configNote = loadNote(read.sentences);
2362
+ this.config = read.config;
2363
+ this.compiledPolicies = this.compilePolicies(read.config);
789
2364
  this.refreshShadowWarnings('policy-mutation');
790
2365
  this.configSource = 'file';
791
- this.configErrors = [];
2366
+ this.configError = this.configNote;
2367
+ this.configUnused = false;
2368
+ this.startUnsaved = null;
792
2369
  this.lastReloadTimestamp = Date.now();
793
- try {
794
- this.configMtime = statSync(this.configPath).mtimeMs;
795
- }
796
- catch {
797
- // A failed stat only risks one redundant reload; keep the old mtime.
798
- }
2370
+ this.clearStateFor(was, read.config);
799
2371
  }
800
2372
  // =========================================================================
801
2373
  // Policy matching — first match wins (passthrough observers excepted; the
@@ -811,9 +2383,17 @@ export class EventGate {
811
2383
  if (compiled.sourceRegex && !compiled.sourceRegex.test(info.serverId)) {
812
2384
  return false;
813
2385
  }
814
- // Channel check
2386
+ // Channel check: the event's own channel id, or the registered id the
2387
+ // gate resolves for its route. A push event carries the adapter's raw id
2388
+ // (`origin.channelId`, or none), and a channels/incoming message the
2389
+ // registered one, so a pattern in the registered form matches every
2390
+ // event in its channel whose push names the channel (by `mcplChannelId`,
2391
+ // or a Discord origin's guild and channel): its messages on both paths,
2392
+ // and its reactions, edits, voice reports and other pushes.
815
2393
  if (compiled.channelRegex && !compiled.channelRegex.test(info.channelId)) {
816
- return false;
2394
+ const registered = this.routeChannelFor(info);
2395
+ if (registered === undefined || !compiled.channelRegex.test(registered))
2396
+ return false;
817
2397
  }
818
2398
  // Mount check (workspace fs events)
819
2399
  if (compiled.mountRegex) {
@@ -883,10 +2463,9 @@ export class EventGate {
883
2463
  // marker requires too: a `source` in an event's metadata is the server's
884
2464
  // own word, which any server can give.
885
2465
  if (this.sleepUntil > this.now()) {
886
- this.reloadPrivilegedIfChanged();
887
2466
  const isHeartbeat = info.serverId === 'heartbeat';
888
2467
  const authorId = this.extractAuthorId(info.metadata);
889
- const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
2468
+ const privileged = this.isPrivilegedUser(authorId);
890
2469
  if (!isHeartbeat && !privileged) {
891
2470
  this.sleepSuppressed++;
892
2471
  this.totalEvaluations++;
@@ -1007,17 +2586,25 @@ export class EventGate {
1007
2586
  // =========================================================================
1008
2587
  // rate_limit + passive_sample + resets
1009
2588
  // =========================================================================
1010
- /** Resolve the partition key for keyBy-style behaviors. */
1011
- resolveKey(metadata, keyBy) {
2589
+ /** Resolve the partition key for keyBy-style behaviors. `keyBy:
2590
+ * "channelId"` takes the registered id the gate resolves for the event when
2591
+ * it has one, as channel matching does, so a channel's push events count
2592
+ * with its channels/incoming messages rather than under their raw id. */
2593
+ resolveKey(info, keyBy) {
1012
2594
  if (!keyBy)
1013
2595
  return SHARED_BUCKET_KEY;
1014
- const value = metadata?.[keyBy];
2596
+ if (keyBy === 'channelId') {
2597
+ const registered = this.routeChannelFor(info);
2598
+ if (registered !== undefined)
2599
+ return registered;
2600
+ }
2601
+ const value = info.metadata?.[keyBy];
1015
2602
  if (value === undefined || value === null || value === '')
1016
2603
  return SHARED_BUCKET_KEY;
1017
2604
  return String(value);
1018
2605
  }
1019
2606
  applyRateLimit(policy, info, config) {
1020
- const key = this.resolveKey(info.metadata, config.keyBy);
2607
+ const key = this.resolveKey(info, config.keyBy);
1021
2608
  let buckets = this.rateLimitBuckets.get(policy.name);
1022
2609
  if (!buckets) {
1023
2610
  buckets = new Map();
@@ -1031,7 +2618,15 @@ export class EventGate {
1031
2618
  }
1032
2619
  else if (bucket.tokens < config.tokens) {
1033
2620
  const elapsed = now - bucket.lastRefill;
1034
- if (elapsed > 0) {
2621
+ // A refill interval of 0, read from gate.json or the starting rules
2622
+ // (readRateLimit), refills at once; divided by, it refilled the bucket
2623
+ // once and left its clock at NaN. Under 0, no number of whole intervals
2624
+ // has passed, so it never refills.
2625
+ if (config.refillIntervalMs === 0) {
2626
+ bucket.tokens = config.tokens;
2627
+ bucket.lastRefill = now;
2628
+ }
2629
+ else if (elapsed > 0) {
1035
2630
  const tokensToAdd = Math.floor(elapsed / config.refillIntervalMs);
1036
2631
  if (tokensToAdd > 0) {
1037
2632
  bucket.tokens = Math.min(config.tokens, bucket.tokens + tokensToAdd);
@@ -1053,7 +2648,7 @@ export class EventGate {
1053
2648
  return { trigger: false, policyName: policy.name, behavior: policy.behavior };
1054
2649
  }
1055
2650
  applyPassiveSample(policy, info, config) {
1056
- const key = this.resolveKey(info.metadata, config.keyBy);
2651
+ const key = this.resolveKey(info, config.keyBy);
1057
2652
  let counters = this.passiveSampleCounters.get(policy.name);
1058
2653
  if (!counters) {
1059
2654
  counters = new Map();
@@ -1083,21 +2678,17 @@ export class EventGate {
1083
2678
  }
1084
2679
  }
1085
2680
  /**
1086
- * Clear ALL per-policy state (debounce timer, rate-limit buckets,
1087
- * passive-sample counters, denial / fire counters) for one policy.
1088
- * Used by reloadIfChanged when a policy is removed or its behavior
1089
- * params change. Stats (matchCount, lastMatchTimestamp) are preserved
1090
- * — those are historical and shouldn't reset on a config edit.
2681
+ * Clear one policy's live state (rate-limit buckets, passive-sample
2682
+ * counters, denial / fire counters) and stop its debounce timer, leaving
2683
+ * its pending batch for fireDebounce to send, as clearStateFor does once
2684
+ * every policy's state is cleared. It leaves stats (matchCount,
2685
+ * lastMatchTimestamp), which are history; clearStateFor deletes a removed
2686
+ * policy's.
1091
2687
  */
1092
- clearPolicyState(name, options) {
2688
+ clearPolicyState(name) {
1093
2689
  const debounce = this.debounceTimers.get(name);
1094
- if (debounce) {
2690
+ if (debounce)
1095
2691
  clearTimeout(debounce.timer);
1096
- if (options.deliverPendingDebounce && debounce.events.length > 0) {
1097
- this.deliverEvents(debounce.events);
1098
- }
1099
- this.debounceTimers.delete(name);
1100
- }
1101
2692
  this.rateLimitBuckets.delete(name);
1102
2693
  this.passiveSampleCounters.delete(name);
1103
2694
  this.rateLimitDenied.delete(name);
@@ -1122,12 +2713,16 @@ export class EventGate {
1122
2713
  return false; // batches; never triggers inline
1123
2714
  if ('rate_limit' in b) {
1124
2715
  const cfg = b.rate_limit;
1125
- const key = this.resolveKey(info.metadata, cfg.keyBy);
2716
+ const key = this.resolveKey(info, cfg.keyBy);
1126
2717
  const bucket = this.rateLimitBuckets.get(policy.name)?.get(key);
1127
2718
  if (!bucket)
1128
2719
  return true; // fresh bucket starts full
2720
+ // As applyRateLimit refills: at once, for an interval of 0, and never
2721
+ // for one under 0.
2722
+ if (cfg.refillIntervalMs === 0)
2723
+ return true;
1129
2724
  let tokens = bucket.tokens;
1130
- if (tokens < cfg.tokens) {
2725
+ if (tokens < cfg.tokens && cfg.refillIntervalMs > 0) {
1131
2726
  const elapsed = Math.max(0, this.now() - bucket.lastRefill);
1132
2727
  tokens = Math.min(cfg.tokens, tokens + Math.floor(elapsed / cfg.refillIntervalMs));
1133
2728
  }
@@ -1135,7 +2730,7 @@ export class EventGate {
1135
2730
  }
1136
2731
  if ('passive_sample' in b) {
1137
2732
  const cfg = b.passive_sample;
1138
- const key = this.resolveKey(info.metadata, cfg.keyBy);
2733
+ const key = this.resolveKey(info, cfg.keyBy);
1139
2734
  const count = this.passiveSampleCounters.get(policy.name)?.get(key) ?? 0;
1140
2735
  return count + 1 >= cfg.every;
1141
2736
  }
@@ -1161,16 +2756,8 @@ export class EventGate {
1161
2756
  const trigger = (this.config.default ?? 'always') === 'always';
1162
2757
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
1163
2758
  }
1164
- /**
1165
- * Probe a canonical set of synthetic chat/heartbeat events and report which
1166
- * rule wins each and whether it would wake. Backs the before/after table in
1167
- * the wake_add_rule result — the point where a rule change's real semantics
1168
- * must become legible (af#105; the Mythos stand-back-density incident would
1169
- * have been visible as `dm: discord-direct-address → stand-back-density`).
1170
- * Probes are representative shapes, not an exhaustive event space — rules
1171
- * keyed on specific channels/mounts may not match any probe.
1172
- */
1173
- probeTable() {
2759
+ /** The canonical synthetic events the probe table runs: chat shapes as MCPL delivers them, and a heartbeat. */
2760
+ probeEvents() {
1174
2761
  const chat = (label, eventType, metadata, tags) => ({
1175
2762
  label,
1176
2763
  info: {
@@ -1201,7 +2788,32 @@ export class EventGate {
1201
2788
  },
1202
2789
  },
1203
2790
  ];
1204
- return probes.map(({ label, info }) => {
2791
+ return probes;
2792
+ }
2793
+ /**
2794
+ * The rules no probe event matches, such as one scoped to an event type
2795
+ * the probes don't model (`external-message`, how a module's messages are
2796
+ * gated): the probe table can't show what they do, so a result that shows
2797
+ * the table names them rather than let its rows read as their effect.
2798
+ */
2799
+ probeUnreached() {
2800
+ const events = this.probeEvents().map(({ info }) => info);
2801
+ return this.compiledPolicies
2802
+ .filter((compiled) => !events.some((info) => this.compiledMatches(compiled, info)))
2803
+ .map((compiled) => compiled.policy.name);
2804
+ }
2805
+ /**
2806
+ * Probe a canonical set of synthetic chat/heartbeat events and report which
2807
+ * rule wins each and whether it would wake. Backs the before/after table in
2808
+ * the wake_add_rule result — the point where a rule change's real semantics
2809
+ * must become legible (af#105; the Mythos stand-back-density incident would
2810
+ * have been visible as `dm: discord-direct-address → stand-back-density`).
2811
+ * Probes are representative shapes, not an exhaustive event space — rules
2812
+ * keyed on specific channels/mounts, or on other event types, may not match
2813
+ * any probe; `probeUnreached` names those.
2814
+ */
2815
+ probeTable() {
2816
+ return this.probeEvents().map(({ label, info }) => {
1205
2817
  const d = this.probe(info);
1206
2818
  return {
1207
2819
  probe: label,
@@ -1294,6 +2906,8 @@ export class EventGate {
1294
2906
  serverId: info.serverId || undefined,
1295
2907
  addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
1296
2908
  routeChannelId: this.routeChannelFor(info),
2909
+ ...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
2910
+ ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
1297
2911
  };
1298
2912
  const existing = this.debounceTimers.get(policy.name);
1299
2913
  if (existing) {
@@ -1322,7 +2936,19 @@ export class EventGate {
1322
2936
  this.bufferForInference(events);
1323
2937
  return;
1324
2938
  }
1325
- this.deliverEvents(events);
2939
+ // A delivery that throws drops the batch, named in the log: thrown from
2940
+ // its timer, it would be uncaught, and stop the process, and thrown to a
2941
+ // write or reload that sends it (clearStateFor), it would report a change
2942
+ // that was made as failed.
2943
+ try {
2944
+ this.deliverEvents(events);
2945
+ }
2946
+ catch (err) {
2947
+ const one = events.length === 1;
2948
+ console.error(`[gate] Policy "${policyName}": its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
2949
+ + `(${errorText(err)}), so the batch is dropped`);
2950
+ return;
2951
+ }
1326
2952
  this.emitTrace({
1327
2953
  type: 'gate:debounce-delivered',
1328
2954
  policyName,
@@ -1345,48 +2971,94 @@ export class EventGate {
1345
2971
  const quoted = events.filter(e => !e.inContext);
1346
2972
  const referenced = events.filter(e => e.inContext);
1347
2973
  const lines = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`);
2974
+ // How each referenced channel is named in the line, in order.
2975
+ const shown = [];
1348
2976
  if (referenced.length > 0) {
2977
+ // By channel, its server and its id: two servers' channels can share
2978
+ // an id.
1349
2979
  const byChannel = new Map();
1350
2980
  for (const e of referenced) {
1351
2981
  // The registered id when known, so this line names the channel the
1352
2982
  // same way routing notices and send tools do (a push event's own
1353
- // channelId is the adapter's raw id).
1354
- const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)';
1355
- const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel };
2983
+ // channelId is the adapter's raw id), shown as the agent names it
2984
+ // back where the event names its server.
2985
+ // Only a registered id is a channel name; an adapter's raw id is
2986
+ // shown as it is.
2987
+ const id = e.routeChannelId ?? e.channelId;
2988
+ const name = id === undefined
2989
+ ? '(unknown channel)'
2990
+ : e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn
2991
+ ? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId })
2992
+ : id;
2993
+ const key = JSON.stringify([e.serverId ?? null, id ?? null]);
2994
+ const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel };
1356
2995
  entry.count += 1;
1357
2996
  entry.oldest = Math.min(entry.oldest, e.timestamp);
1358
2997
  entry.label = entry.label ?? e.channelLabel;
1359
2998
  byChannel.set(key, entry);
1360
2999
  }
1361
- for (const [channelId, { count, oldest, label }] of byChannel) {
3000
+ for (const { name, count, oldest, label } of byChannel.values()) {
1362
3001
  // Prefer the human-readable name; keep the id parenthesized so the
1363
3002
  // agent can still address tools that want the composite id. A name
1364
3003
  // may carry its own `#`, so one is added only where the name lacks
1365
3004
  // it, as at the other places that add a `#`.
1366
- const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId;
1367
- const age = Math.round((Date.now() - oldest) / 1000);
1368
- lines.push(`- ${count} message${count > 1 ? 's' : ''} in ${channel} ` +
1369
- `(oldest ~${age}s ago) — already shown above at arrival; this is a ` +
1370
- `batched wake, not new content`);
3005
+ const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name;
3006
+ shown.push(channel);
3007
+ lines.push(referenceLine(count, channel, oldest, Date.now()));
1371
3008
  }
1372
3009
  }
3010
+ // The line says which messages a wake is for. For one message that is
3011
+ // still the newest thing in the window it would be written to, it says
3012
+ // nothing the message right above doesn't. But the turn compiles later,
3013
+ // and what lands in between puts the message above other entries, so the
3014
+ // gate hands the line to the wake (WakeProvenance.line) and a fresh start
3015
+ // of the wake's turn decides it (WakeLine). Several messages, one with
3016
+ // anything after it, or content the line quotes, keep the line now.
1373
3017
  const text = `[Gate: ${events.length} event${events.length > 1 ? 's' : ''} matched]\n\n${lines.join('\n')}`;
1374
- this.addMessageFn('user', [{ type: 'text', text }], {
1375
- source: 'gate:debounce',
1376
- policies: policyNames,
1377
- });
3018
+ const only = events.length === 1 && events[0].inContext ? events[0] : undefined;
3019
+ let line;
3020
+ if (only && this.newestEntry(only)) {
3021
+ const shownChannel = only.routeChannelId ?? only.channelId;
3022
+ line = {
3023
+ entry: entryRef(only),
3024
+ policyNames,
3025
+ shown: shown[0],
3026
+ at: only.timestamp,
3027
+ ...(shownChannel !== undefined ? { channelId: shownChannel } : {}),
3028
+ };
3029
+ }
3030
+ else {
3031
+ this.addMessageFn('user', [{ type: 'text', text }], {
3032
+ source: 'gate:debounce',
3033
+ policies: policyNames,
3034
+ });
3035
+ }
1378
3036
  // Provenance of the batched wake (who/where woke the agent): the newest
1379
3037
  // ADDRESSED event first, else the newest event naming a channel or an
1380
3038
  // author; channel + author + addressed from that ONE event. When that
1381
3039
  // event addressed the agent and its registered channel is known,
1382
3040
  // `routeChannelId` names the turn's speech locus too — the reply goes
1383
3041
  // back to whoever addressed the agent, not to whichever channel last saw
1384
- // any traffic. Ambient-only batches carry telemetry only.
1385
- const provenance = wakeProvenance(events);
3042
+ // any traffic. Ambient-only batches set no locus. A batch whose line the
3043
+ // gate left to the turn's start carries it too (`line`), one object
3044
+ // shared by every agent the batch wakes, so it is written once.
3045
+ const chosen = wakeProvenance(events);
3046
+ const provenance = line ? { ...chosen, line } : chosen;
1386
3047
  for (const agentName of this.getAgentNamesFn()) {
1387
3048
  this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance);
1388
3049
  }
1389
3050
  }
3051
+ /** isNewestEntry for one event; a callback fault keeps the line. */
3052
+ newestEntry(event) {
3053
+ if (!this.isNewestEntryFn)
3054
+ return false;
3055
+ try {
3056
+ return this.isNewestEntryFn(entryRef(event)) === true;
3057
+ }
3058
+ catch {
3059
+ return false;
3060
+ }
3061
+ }
1390
3062
  // =========================================================================
1391
3063
  // Inference lifecycle
1392
3064
  // =========================================================================
@@ -1436,8 +3108,19 @@ export class EventGate {
1436
3108
  return;
1437
3109
  if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) {
1438
3110
  const events = this.inferenceBuffer.splice(0);
1439
- // Defer to avoid re-entrancy inside trace callbacks
1440
- queueMicrotask(() => this.deliverEvents(events));
3111
+ // Defer to avoid re-entrancy inside trace callbacks. A delivery that
3112
+ // throws drops what was held, named in the log, as fireDebounce drops a
3113
+ // batch: thrown in the microtask, it would be uncaught.
3114
+ queueMicrotask(() => {
3115
+ try {
3116
+ this.deliverEvents(events);
3117
+ }
3118
+ catch (err) {
3119
+ const one = events.length === 1;
3120
+ console.error(`[gate] ${events.length} ${one ? 'event' : 'events'} held for an agent's turn or the host's quiesce couldn't be `
3121
+ + `delivered (${errorText(err)}), so ${one ? "it's" : "they're"} dropped`);
3122
+ }
3123
+ });
1441
3124
  }
1442
3125
  }
1443
3126
  /** Live inference-gating snapshot for diagnostics (e.g. a SIGUSR2 dump).
@@ -1535,10 +3218,9 @@ export class EventGate {
1535
3218
  }
1536
3219
  /**
1537
3220
  * `agentName` is gone, as a conversation fork's end or an ephemeral run's
1538
- * removes one: its pending self-wake is cancelled, and a sleep it set ends.
1539
- * The gate's sleep holds back every agent's wakes, so nobody is asleep once
1540
- * its agent is gone, and neither wake would reach a name that no longer
1541
- * exists. A sleep another agent set, or one set with no agent, stays.
3221
+ * removes one: its pending self-wake is cancelled, rather than wake a name
3222
+ * that no longer exists. It holds no sleep: neither can set one (the
3223
+ * framework refuses their `sleep`).
1542
3224
  */
1543
3225
  agentGone(agentName) {
1544
3226
  const selfWake = this.selfWakeTimers.get(agentName);
@@ -1546,8 +3228,6 @@ export class EventGate {
1546
3228
  clearTimeout(selfWake);
1547
3229
  this.selfWakeTimers.delete(agentName);
1548
3230
  }
1549
- if (this.sleepUntil !== 0 && this.sleepAgent === agentName)
1550
- this.clearSleep();
1551
3231
  }
1552
3232
  // =========================================================================
1553
3233
  // Self-wake (skip_reply wake_in_seconds)
@@ -1586,12 +3266,16 @@ export class EventGate {
1586
3266
  const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
1587
3267
  // Deliver the notice into the WAKING agent's window — a self-wake
1588
3268
  // armed by a non-primary agent (e.g. a subconscious cadence) must not
1589
- // narrate itself into the primary's context.
3269
+ // narrate itself into the primary's context. Its wake goes with it: an
3270
+ // agent that heard the notice during a turn was awake when its timer
3271
+ // fired, and the host drops the wake once a request that carried the
3272
+ // notice is answered.
3273
+ const placement = {};
1590
3274
  this.addMessageFn('user', [{
1591
3275
  type: 'text',
1592
3276
  text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
1593
- }], { source: 'gate:self-wake' }, agentName);
1594
- this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake');
3277
+ }], { source: 'gate:self-wake' }, agentName, placement);
3278
+ this.requestInferenceFn(agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, 'self-wake', undefined, placement);
1595
3279
  }, ms);
1596
3280
  this.selfWakeTimers.set(agentName, timer);
1597
3281
  this.emitTrace({
@@ -1675,7 +3359,6 @@ export class EventGate {
1675
3359
  return String(metadata.authorId);
1676
3360
  return null;
1677
3361
  }
1678
- /** Load the privileged-users file (bare array or { userIds: [...] }). */
1679
3362
  /**
1680
3363
  * Whether an author id is on the privileged-users list (hot-reloaded).
1681
3364
  * Shared by sleep bypass and tune-out wake classification (#77: wakes are
@@ -1684,48 +3367,82 @@ export class EventGate {
1684
3367
  isPrivilegedUser(authorId) {
1685
3368
  if (!authorId)
1686
3369
  return false;
1687
- this.loadPrivileged();
3370
+ this.reloadPrivilegedIfChanged();
1688
3371
  return this.privilegedUserIds.has(authorId);
1689
3372
  }
1690
- loadPrivileged() {
3373
+ /**
3374
+ * Read the privileged-users file: a list of user ids, or `{"userIds": [...]}`,
3375
+ * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
3376
+ * means no one is privileged. A file that can't be read, isn't JSON, or
3377
+ * holds no list leaves the list that last loaded in this process in effect
3378
+ * (none, at startup), with its error in gate_status until the file is read
3379
+ * again. In a list, an entry that isn't a user id is skipped, and named in
3380
+ * the file's error, and the rest load. With `unlessUnchanged`, a file whose
3381
+ * text is the text last read isn't parsed again.
3382
+ */
3383
+ loadPrivileged(opts) {
1691
3384
  if (!this.privilegedUsersPath)
1692
3385
  return;
3386
+ const failed = (why) => `Failed to load privileged-users file: ${why}`;
3387
+ let raw;
1693
3388
  try {
1694
- if (!existsSync(this.privilegedUsersPath)) {
3389
+ raw = readFileSync(this.privilegedUsersPath, 'utf8');
3390
+ }
3391
+ catch (err) {
3392
+ // Read again next time: a file can become readable with its text
3393
+ // unchanged, as a chmod makes it.
3394
+ this.privilegedText = undefined;
3395
+ if (isMissing(err)) {
1695
3396
  this.privilegedUserIds = new Set();
1696
- this.privilegedMtime = 0;
1697
- return;
3397
+ this.privilegedError = null;
1698
3398
  }
1699
- this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs;
1700
- const raw = parseGateJson(readFileSync(this.privilegedUsersPath, 'utf8'));
1701
- const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : [];
1702
- this.privilegedUserIds = new Set(ids.map((x) => String(x)));
3399
+ else {
3400
+ this.privilegedError = failed(err instanceof Error ? err.message : String(err));
3401
+ }
3402
+ return;
3403
+ }
3404
+ if (opts?.unlessUnchanged && raw === this.privilegedText)
3405
+ return;
3406
+ this.privilegedText = raw;
3407
+ let ids;
3408
+ try {
3409
+ const parsed = parseGateJson(raw);
3410
+ ids = Array.isArray(parsed) ? parsed : parsed?.userIds;
1703
3411
  }
1704
3412
  catch (err) {
1705
- this.configErrors.push(`Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`);
3413
+ this.privilegedError = failed(err instanceof Error ? err.message : String(err));
3414
+ return;
1706
3415
  }
1707
- }
1708
- /** Throttled mtime-based reload of the privileged-users file. */
3416
+ // A file that parses but holds no list hasn't loaded either: it used to
3417
+ // mean no one is privileged, without a word.
3418
+ if (!Array.isArray(ids)) {
3419
+ this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}');
3420
+ return;
3421
+ }
3422
+ const loaded = new Set();
3423
+ const skipped = [];
3424
+ for (const id of ids) {
3425
+ if (typeof id === 'string' && id !== '')
3426
+ loaded.add(id);
3427
+ // JSON.parse has already rounded an integer past 2^53, and the rounded
3428
+ // value can be another user's id, so it's skipped rather than loaded.
3429
+ else if (typeof id === 'number' && Number.isSafeInteger(id))
3430
+ loaded.add(String(id));
3431
+ else
3432
+ skipped.push(id);
3433
+ }
3434
+ this.privilegedUserIds = loaded;
3435
+ this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
3436
+ }
3437
+ /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
1709
3438
  reloadPrivilegedIfChanged() {
1710
3439
  if (!this.privilegedUsersPath)
1711
3440
  return;
1712
3441
  const now = this.now();
1713
- if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS)
3442
+ if (checkedRecently(now, this.lastPrivilegedCheck))
1714
3443
  return;
1715
3444
  this.lastPrivilegedCheck = now;
1716
- try {
1717
- if (!existsSync(this.privilegedUsersPath)) {
1718
- if (this.privilegedUserIds.size > 0)
1719
- this.privilegedUserIds = new Set();
1720
- return;
1721
- }
1722
- const mtime = statSync(this.privilegedUsersPath).mtimeMs;
1723
- if (mtime !== this.privilegedMtime)
1724
- this.loadPrivileged();
1725
- }
1726
- catch {
1727
- /* leave the last-known set in place on a transient stat/read error */
1728
- }
3445
+ this.loadPrivileged({ unlessUnchanged: true });
1729
3446
  }
1730
3447
  getToolDefinition() {
1731
3448
  return {
@@ -1737,6 +3454,10 @@ export class EventGate {
1737
3454
  };
1738
3455
  }
1739
3456
  async handleToolCall() {
3457
+ // What's in force now: each file read again if a check is due, as the
3458
+ // next event would.
3459
+ this.reloadIfChanged();
3460
+ this.reloadPrivilegedIfChanged();
1740
3461
  const status = this.getStatus();
1741
3462
  return { success: true, data: status };
1742
3463
  }
@@ -1792,7 +3513,8 @@ export class EventGate {
1792
3513
  default: this.config.default ?? 'always',
1793
3514
  script: this.gateScript.status(),
1794
3515
  policies,
1795
- errors: [...this.configErrors],
3516
+ errors: [this.configErrorShown(), this.startNotice, this.startUnsaved, this.privilegedError]
3517
+ .filter((e) => e !== null),
1796
3518
  shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
1797
3519
  totalEvaluations: this.totalEvaluations,
1798
3520
  defaultDecisions: {