@animalabs/agent-framework 0.9.0 → 0.11.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 (109) hide show
  1. package/dist/src/agent.d.ts +1 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +72 -4
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +699 -48
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/gate/event-gate.d.ts +62 -7
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +333 -22
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/gate/types.d.ts +56 -0
  13. package/dist/src/gate/types.d.ts.map +1 -1
  14. package/dist/src/mcpl/prose-grammar.d.ts +11 -0
  15. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  16. package/dist/src/mcpl/prose-grammar.js +21 -0
  17. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  18. package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
  19. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  20. package/dist/src/mcpl/prose-stream-router.js +57 -13
  21. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  22. package/dist/src/modules/discord/index.d.ts.map +1 -1
  23. package/dist/src/modules/discord/index.js +21 -5
  24. package/dist/src/modules/discord/index.js.map +1 -1
  25. package/dist/src/modules/workspace/index.d.ts +23 -0
  26. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  27. package/dist/src/modules/workspace/index.js +53 -3
  28. package/dist/src/modules/workspace/index.js.map +1 -1
  29. package/dist/src/modules/workspace/sync.d.ts +11 -2
  30. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/sync.js +27 -11
  32. package/dist/src/modules/workspace/sync.js.map +1 -1
  33. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/watcher.js +9 -6
  35. package/dist/src/modules/workspace/watcher.js.map +1 -1
  36. package/dist/src/queue.d.ts.map +1 -1
  37. package/dist/src/queue.js +9 -2
  38. package/dist/src/queue.js.map +1 -1
  39. package/dist/src/tool-result-history.d.ts +12 -2
  40. package/dist/src/tool-result-history.d.ts.map +1 -1
  41. package/dist/src/tool-result-history.js +12 -2
  42. package/dist/src/tool-result-history.js.map +1 -1
  43. package/dist/src/types/agent.d.ts +6 -1
  44. package/dist/src/types/agent.d.ts.map +1 -1
  45. package/dist/src/types/framework.d.ts +1 -1
  46. package/dist/src/types/trace.d.ts +7 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/busy-agent-scheduler.test.js +29 -0
  49. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  50. package/dist/test/discord-history-delete-window.test.d.ts +2 -0
  51. package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
  52. package/dist/test/discord-history-delete-window.test.js +81 -0
  53. package/dist/test/discord-history-delete-window.test.js.map +1 -0
  54. package/dist/test/discord-history-sync.test.d.ts +2 -0
  55. package/dist/test/discord-history-sync.test.d.ts.map +1 -0
  56. package/dist/test/discord-history-sync.test.js +56 -0
  57. package/dist/test/discord-history-sync.test.js.map +1 -0
  58. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  59. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  60. package/dist/test/event-gate-passthrough.test.js +289 -0
  61. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  62. package/dist/test/explicit-prose-routing.test.js +179 -7
  63. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  64. package/dist/test/framework.test.js +33 -0
  65. package/dist/test/framework.test.js.map +1 -1
  66. package/dist/test/prose-stream-router.test.js +43 -0
  67. package/dist/test/prose-stream-router.test.js.map +1 -1
  68. package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
  69. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
  70. package/dist/test/provider-acceleration-cooldown.test.js +286 -0
  71. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
  72. package/dist/test/puppet-tool-call.test.d.ts +2 -0
  73. package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
  74. package/dist/test/puppet-tool-call.test.js +106 -0
  75. package/dist/test/puppet-tool-call.test.js.map +1 -0
  76. package/dist/test/release-changelog.test.d.ts +2 -0
  77. package/dist/test/release-changelog.test.d.ts.map +1 -0
  78. package/dist/test/release-changelog.test.js +159 -0
  79. package/dist/test/release-changelog.test.js.map +1 -0
  80. package/dist/test/tool-result-spill.test.d.ts +1 -1
  81. package/dist/test/tool-result-spill.test.js +18 -9
  82. package/dist/test/tool-result-spill.test.js.map +1 -1
  83. package/dist/test/workspace-path-containment.test.d.ts +19 -0
  84. package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
  85. package/dist/test/workspace-path-containment.test.js +91 -0
  86. package/dist/test/workspace-path-containment.test.js.map +1 -0
  87. package/dist/test/workspace-watcher.test.js +15 -3
  88. package/dist/test/workspace-watcher.test.js.map +1 -1
  89. package/dist/test/xml-round-persist.test.d.ts +19 -0
  90. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  91. package/dist/test/xml-round-persist.test.js +235 -0
  92. package/dist/test/xml-round-persist.test.js.map +1 -0
  93. package/dist/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +2 -1
  95. package/src/agent.ts +1 -1
  96. package/src/framework.ts +733 -48
  97. package/src/gate/event-gate.ts +355 -24
  98. package/src/gate/types.ts +58 -0
  99. package/src/mcpl/prose-grammar.ts +21 -0
  100. package/src/mcpl/prose-stream-router.ts +48 -14
  101. package/src/modules/discord/index.ts +24 -6
  102. package/src/modules/workspace/index.ts +53 -3
  103. package/src/modules/workspace/sync.ts +39 -10
  104. package/src/modules/workspace/watcher.ts +9 -6
  105. package/src/queue.ts +8 -2
  106. package/src/tool-result-history.ts +12 -2
  107. package/src/types/agent.ts +6 -1
  108. package/src/types/framework.ts +1 -1
  109. package/src/types/trace.ts +13 -0
@@ -11,12 +11,24 @@
11
11
  * inference tokens, not whether the agent should know about the event.
12
12
  */
13
13
  import type { ToolDefinition } from '../types/events.js';
14
- import type { GateConfig, GatePolicy, GateDecision, GateEventInfo, GateStatus } from './types.js';
14
+ import type { GateConfig, GatePolicy, GateDecision, GateEventInfo, GateStatus, ShadowWarning, GateProbeRow } from './types.js';
15
15
  export type { GateConfig, GateOptions, GateDecision, GateEventInfo } from './types.js';
16
16
  interface TraceEventLike {
17
17
  type: string;
18
18
  [key: string]: unknown;
19
19
  }
20
+ /**
21
+ * Find rules made unreachable by earlier, broader rules (first match wins).
22
+ * The 2026-08-08 Mythos incident is the canonical catch: a prepended
23
+ * `{scope:[mcpl:channel-incoming]}` sampler silently made the
24
+ * direct-address and mention rules dead for the whole incoming lane.
25
+ * `passthrough` observers shadow nothing — non-firing matches fall through.
26
+ */
27
+ export declare function findShadowedPolicies(policies: GatePolicy[]): ShadowWarning[];
28
+ /** Human-readable one-liner for a ShadowWarning, used in tool results,
29
+ * traces, and gate_status alike so the same hazard reads the same way
30
+ * everywhere. */
31
+ export declare function formatShadowWarning(w: ShadowWarning): string;
20
32
  export declare class EventGate {
21
33
  private configPath;
22
34
  private config;
@@ -36,6 +48,8 @@ export declare class EventGate {
36
48
  private inferring;
37
49
  private inferenceBuffer;
38
50
  private stats;
51
+ private shadowWarningsCache;
52
+ private shadowWarningsFingerprint;
39
53
  private totalEvaluations;
40
54
  private defaultTriggered;
41
55
  private defaultSkipped;
@@ -87,13 +101,21 @@ export declare class EventGate {
87
101
  * without waiting for the throttled mtime reload.
88
102
  *
89
103
  * The freshest on-disk config is re-read first, so a concurrent operator edit
90
- * isn't clobbered. A policy with the same `name` is replaced IN PLACE (order
91
- * preserved); otherwise it is appended, or prepended when `position:'prepend'`
92
- * — first match wins, so a wake rule that must beat a broad defer/debounce
93
- * belongs at the front.
104
+ * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no
105
+ * `position` is given (order preserved); WITH a `position` it is moved there
106
+ * — so re-issuing a rule is also how you reposition it. New policies append
107
+ * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING,
108
+ * including addressed-message wake rules — see the shadow lint), and the
109
+ * anchored forms `{before: name}` / `{after: name}` insert relative to an
110
+ * existing rule, which is almost always the safer way to order a sampler
111
+ * or debounce against a specific catch-all.
94
112
  */
95
113
  addPolicy(rawPolicy: unknown, options?: {
96
- position?: 'append' | 'prepend';
114
+ position?: 'append' | 'prepend' | {
115
+ before: string;
116
+ } | {
117
+ after: string;
118
+ };
97
119
  }): GatePolicy;
98
120
  /**
99
121
  * Remove a policy by name from gate.json and clear its live state. Any pending
@@ -115,7 +137,6 @@ export declare class EventGate {
115
137
  * if the write fails (disk/permission) — callers surface it as a tool error.
116
138
  */
117
139
  private writeConfig;
118
- private matchPolicy;
119
140
  private compiledMatches;
120
141
  evaluate(info: GateEventInfo): GateDecision;
121
142
  private computeDecision;
@@ -137,6 +158,40 @@ export declare class EventGate {
137
158
  * — those are historical and shouldn't reset on a config edit.
138
159
  */
139
160
  private clearPolicyState;
161
+ /**
162
+ * Non-mutating twin of the behavior decision: would this policy trigger for
163
+ * this event, given CURRENT counter/bucket state? Reads state, never writes
164
+ * it — no stats, no traces, no counter increments, no debounce batching.
165
+ */
166
+ private wouldTrigger;
167
+ /**
168
+ * Evaluate an event against the declarative policy table WITHOUT side
169
+ * effects: no counters advance, no debounce batches form, no stats or
170
+ * traces are recorded, and sleep suppression is ignored (a probe asks
171
+ * about the table, not the moment). gate.js is not consulted — a script
172
+ * may be stateful, and a probe must never perturb state.
173
+ */
174
+ probe(info: GateEventInfo): GateDecision;
175
+ /**
176
+ * Probe a canonical set of synthetic chat/heartbeat events and report which
177
+ * rule wins each and whether it would wake. Backs the before/after table in
178
+ * the wake_add_rule result — the point where a rule change's real semantics
179
+ * must become legible (af#105; the Mythos stand-back-density incident would
180
+ * have been visible as `dm: discord-direct-address → stand-back-density`).
181
+ * Probes are representative shapes, not an exhaustive event space — rules
182
+ * keyed on specific channels/mounts may not match any probe.
183
+ */
184
+ probeTable(): GateProbeRow[];
185
+ /** Current shadow-lint findings for the live policy order. Fresh compute —
186
+ * cheap (policy lists are tens of entries at most). */
187
+ lintShadows(): ShadowWarning[];
188
+ /**
189
+ * Recompute shadow warnings and, when the set changed and is non-empty,
190
+ * emit a `gate:shadow-warnings` trace so the hazard lands in ops
191
+ * observability at the moment the order changed — not at the next missed
192
+ * DM. Called from writeConfig and reloadIfChanged.
193
+ */
194
+ private refreshShadowWarnings;
140
195
  /**
141
196
  * Returns a callback compatible with McplServerConfig.shouldTriggerInference.
142
197
  * Used by the framework to wire the gate into PushHandler and ChannelRegistry.
@@ -1 +1 @@
1
- {"version":3,"file":"event-gate.d.ts","sourceRoot":"","sources":["../../../src/gate/event-gate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"event-gate.d.ts","sourceRoot":"","sources":["../../../src/gate/event-gate.ts"],"names":[],"mappings":"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"}
@@ -238,12 +238,138 @@ function validatePolicy(raw) {
238
238
  if (names.length > 0)
239
239
  resets = names;
240
240
  }
241
+ // Validate passthrough — only meaningful on the counting behaviors, where
242
+ // "matched but didn't fire" is a distinct outcome that can fall through.
243
+ // On always/defer/skip/debounce the flag would be a no-op at best and a
244
+ // misleading promise at worst, so reject it loudly instead of ignoring it.
245
+ let passthrough;
246
+ if (obj.passthrough !== undefined) {
247
+ if (typeof obj.passthrough !== 'boolean') {
248
+ throw new Error(`Policy "${obj.name}": passthrough must be a boolean`);
249
+ }
250
+ if (obj.passthrough) {
251
+ const counting = typeof behavior === 'object'
252
+ && ('rate_limit' in behavior || 'passive_sample' in behavior);
253
+ if (!counting) {
254
+ throw new Error(`Policy "${obj.name}": passthrough is only valid with rate_limit or `
255
+ + `passive_sample behaviors — for "${formatBehavior(behavior)}" there is `
256
+ + 'no non-firing match to fall through on');
257
+ }
258
+ passthrough = true;
259
+ }
260
+ }
241
261
  const policy = { name: obj.name, match, behavior };
242
262
  if (resets)
243
263
  policy.resets = resets;
264
+ if (passthrough)
265
+ policy.passthrough = passthrough;
244
266
  return policy;
245
267
  }
246
268
  // ---------------------------------------------------------------------------
269
+ // Shadow lint — prove (conservatively) when rule ordering makes a rule dead
270
+ // ---------------------------------------------------------------------------
271
+ /** Set-inclusion helper for exact-string lists. */
272
+ function subsetOf(a, b) {
273
+ return a.every((x) => b.includes(x));
274
+ }
275
+ /** A glob field on the EARLIER rule is at least as broad as the LATER rule's
276
+ * when it's absent, the universal "*", or literally identical. Anything
277
+ * cleverer (real glob subsumption) is out of conservative scope. */
278
+ function globAtLeastAsBroad(earlier, later) {
279
+ if (earlier === undefined || earlier === '*')
280
+ return true;
281
+ return earlier === later;
282
+ }
283
+ /**
284
+ * Event types for which `earlier` provably matches EVERY event that `later`
285
+ * would match — i.e. the types for which `later` is unreachable when
286
+ * `earlier` sits before it and consumes its matches. Returns null when
287
+ * subsumption can't be proven (which is not proof of safety — the lint is
288
+ * deliberately one-sided so every warning it emits is true).
289
+ */
290
+ function subsumedEventTypes(earlier, later) {
291
+ const e = earlier.match;
292
+ const l = later.match;
293
+ // Scope: which of `later`'s event types does `earlier` cover?
294
+ let covered;
295
+ if (!e.scope || e.scope.length === 0) {
296
+ covered = l.scope && l.scope.length > 0 ? l.scope : 'all';
297
+ }
298
+ else if (!l.scope || l.scope.length === 0) {
299
+ // `later` matches all types; `earlier` only consumes its own scope, so
300
+ // the shadow is partial — exactly those types.
301
+ covered = e.scope;
302
+ }
303
+ else {
304
+ const overlap = l.scope.filter((t) => e.scope.includes(t));
305
+ if (overlap.length === 0)
306
+ return null;
307
+ covered = overlap;
308
+ }
309
+ // Every remaining discriminator on `earlier` must be at least as broad as
310
+ // `later`'s, otherwise some `later` events slip past `earlier`.
311
+ if (!globAtLeastAsBroad(e.source, l.source))
312
+ return null;
313
+ if (!globAtLeastAsBroad(e.channel, l.channel))
314
+ return null;
315
+ if (!globAtLeastAsBroad(e.mount, l.mount))
316
+ return null;
317
+ if (!globAtLeastAsBroad(e.pathGlob, l.pathGlob))
318
+ return null;
319
+ if (e.filter) {
320
+ if (!l.filter || e.filter.type !== l.filter.type || e.filter.pattern !== l.filter.pattern) {
321
+ return null;
322
+ }
323
+ }
324
+ // metadataTrue / tagsAny are OR-lists: earlier subsumes when later's list
325
+ // is a subset of earlier's (any event satisfying later then satisfies
326
+ // earlier too). tagsAll / tagsNone are AND/NOT constraints: earlier
327
+ // subsumes when its constraints are a subset of later's.
328
+ if (e.metadataTrue && e.metadataTrue.length > 0) {
329
+ if (!l.metadataTrue || !subsetOf(l.metadataTrue, e.metadataTrue))
330
+ return null;
331
+ }
332
+ if (e.tagsAny && e.tagsAny.length > 0) {
333
+ if (!l.tagsAny || !subsetOf(l.tagsAny, e.tagsAny))
334
+ return null;
335
+ }
336
+ if (e.tagsAll && !subsetOf(e.tagsAll, l.tagsAll ?? []))
337
+ return null;
338
+ if (e.tagsNone && !subsetOf(e.tagsNone, l.tagsNone ?? []))
339
+ return null;
340
+ return covered;
341
+ }
342
+ /**
343
+ * Find rules made unreachable by earlier, broader rules (first match wins).
344
+ * The 2026-08-08 Mythos incident is the canonical catch: a prepended
345
+ * `{scope:[mcpl:channel-incoming]}` sampler silently made the
346
+ * direct-address and mention rules dead for the whole incoming lane.
347
+ * `passthrough` observers shadow nothing — non-firing matches fall through.
348
+ */
349
+ export function findShadowedPolicies(policies) {
350
+ const out = [];
351
+ for (let i = 0; i < policies.length; i++) {
352
+ const earlier = policies[i];
353
+ if (earlier.passthrough)
354
+ continue;
355
+ for (let j = i + 1; j < policies.length; j++) {
356
+ const covered = subsumedEventTypes(earlier, policies[j]);
357
+ if (covered) {
358
+ out.push({ earlier: earlier.name, later: policies[j].name, eventTypes: covered });
359
+ }
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+ /** Human-readable one-liner for a ShadowWarning, used in tool results,
365
+ * traces, and gate_status alike so the same hazard reads the same way
366
+ * everywhere. */
367
+ export function formatShadowWarning(w) {
368
+ const types = w.eventTypes === 'all' ? 'ALL event types' : w.eventTypes.join(', ');
369
+ return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
370
+ + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
371
+ }
372
+ // ---------------------------------------------------------------------------
247
373
  // EventGate
248
374
  // ---------------------------------------------------------------------------
249
375
  export class EventGate {
@@ -271,6 +397,10 @@ export class EventGate {
271
397
  inferenceBuffer = [];
272
398
  // Per-policy stats
273
399
  stats = new Map();
400
+ // Shadow-lint cache (see refreshShadowWarnings) — recomputed on every
401
+ // config change, fingerprinted so an unchanged hazard set doesn't re-trace.
402
+ shadowWarningsCache = [];
403
+ shadowWarningsFingerprint = '[]';
274
404
  // Observability counters for events that didn't match any policy
275
405
  totalEvaluations = 0;
276
406
  defaultTriggered = 0;
@@ -360,6 +490,7 @@ export class EventGate {
360
490
  // Load config
361
491
  this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
362
492
  this.compiledPolicies = this.compilePolicies(this.config);
493
+ this.refreshShadowWarnings('startup');
363
494
  // Programmable gate: an optional agent-authored `gate.js` next to gate.json.
364
495
  // Absent ⇒ inactive (zero overhead beyond a throttled stat check).
365
496
  this.gateScript = new GateScript(join(dirname(this.configPath), 'gate.js'), opts.scriptTimeoutMs ?? 50, this.now);
@@ -470,6 +601,7 @@ export class EventGate {
470
601
  this.config = newConfig;
471
602
  this.compiledPolicies = this.compilePolicies(newConfig);
472
603
  this.lastReloadTimestamp = now;
604
+ this.refreshShadowWarnings('reload');
473
605
  this.emitTrace({
474
606
  type: 'gate:config-reloaded',
475
607
  configPath: this.configPath,
@@ -497,24 +629,42 @@ export class EventGate {
497
629
  * without waiting for the throttled mtime reload.
498
630
  *
499
631
  * The freshest on-disk config is re-read first, so a concurrent operator edit
500
- * isn't clobbered. A policy with the same `name` is replaced IN PLACE (order
501
- * preserved); otherwise it is appended, or prepended when `position:'prepend'`
502
- * — first match wins, so a wake rule that must beat a broad defer/debounce
503
- * belongs at the front.
632
+ * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no
633
+ * `position` is given (order preserved); WITH a `position` it is moved there
634
+ * — so re-issuing a rule is also how you reposition it. New policies append
635
+ * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING,
636
+ * including addressed-message wake rules — see the shadow lint), and the
637
+ * anchored forms `{before: name}` / `{after: name}` insert relative to an
638
+ * existing rule, which is almost always the safer way to order a sampler
639
+ * or debounce against a specific catch-all.
504
640
  */
505
641
  addPolicy(rawPolicy, options) {
506
642
  const policy = validatePolicy(rawPolicy);
507
643
  const current = this.loadConfig() ?? this.config;
508
644
  const policies = [...current.policies];
509
645
  const idx = policies.findIndex((p) => p.name === policy.name);
510
- if (idx >= 0) {
511
- policies[idx] = policy;
512
- }
513
- else if (options?.position === 'prepend') {
514
- policies.unshift(policy);
646
+ const position = options?.position;
647
+ if (idx >= 0 && position === undefined) {
648
+ policies[idx] = policy; // replace in place, order preserved
515
649
  }
516
650
  else {
517
- policies.push(policy);
651
+ if (idx >= 0)
652
+ policies.splice(idx, 1); // repositioning a known rule
653
+ if (position === 'prepend') {
654
+ policies.unshift(policy);
655
+ }
656
+ else if (position !== undefined && typeof position === 'object') {
657
+ const anchorName = 'before' in position ? position.before : position.after;
658
+ const anchorIdx = policies.findIndex((p) => p.name === anchorName);
659
+ if (anchorIdx < 0) {
660
+ throw new Error(`addPolicy: no policy named "${anchorName}" to anchor on — `
661
+ + `have: [${policies.map((p) => p.name).join(', ')}]`);
662
+ }
663
+ policies.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
664
+ }
665
+ else {
666
+ policies.push(policy);
667
+ }
518
668
  }
519
669
  this.writeConfig({ ...current, policies });
520
670
  this.emitTrace({
@@ -570,6 +720,7 @@ export class EventGate {
570
720
  writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
571
721
  this.config = config;
572
722
  this.compiledPolicies = this.compilePolicies(config);
723
+ this.refreshShadowWarnings('policy-mutation');
573
724
  this.configSource = 'file';
574
725
  this.configErrors = [];
575
726
  this.lastReloadTimestamp = Date.now();
@@ -581,16 +732,9 @@ export class EventGate {
581
732
  }
582
733
  }
583
734
  // =========================================================================
584
- // Policy matching — first match wins
735
+ // Policy matching — first match wins (passthrough observers excepted; the
736
+ // match/decide loop lives inline in evaluate() and probe())
585
737
  // =========================================================================
586
- matchPolicy(info) {
587
- for (const compiled of this.compiledPolicies) {
588
- if (this.compiledMatches(compiled, info)) {
589
- return compiled.policy;
590
- }
591
- }
592
- return null;
593
- }
594
738
  compiledMatches(compiled, info) {
595
739
  const match = compiled.policy.match;
596
740
  // Scope check
@@ -694,11 +838,33 @@ export class EventGate {
694
838
  // Programmable gate (gate.js) takes precedence when present and returns a
695
839
  // behavior; null/undefined (inactive, error, or a deliberate pass) falls
696
840
  // through to the declarative gate.json policies below.
841
+ //
842
+ // Declarative policies: first match wins — EXCEPT `passthrough` observers,
843
+ // which consume the event only when their counting behavior actually
844
+ // fires. A passthrough policy that matched-but-didn't-fire records its
845
+ // count and evaluation continues, so a density sampler placed early can
846
+ // no longer swallow the addressed-message rules behind it.
697
847
  const scripted = this.gateScript.evaluate(info);
698
- const policy = scripted != null ? { name: 'gate.js', match: {}, behavior: scripted } : this.matchPolicy(info);
699
- const decision = this.computeDecision(policy, info);
848
+ let decision = null;
849
+ const observed = [];
850
+ if (scripted != null) {
851
+ decision = this.computeDecision({ name: 'gate.js', match: {}, behavior: scripted }, info);
852
+ }
853
+ else {
854
+ for (const compiled of this.compiledPolicies) {
855
+ if (!this.compiledMatches(compiled, info))
856
+ continue;
857
+ const candidate = this.computeDecision(compiled.policy, info);
858
+ if (candidate.trigger || !compiled.policy.passthrough) {
859
+ decision = candidate;
860
+ break;
861
+ }
862
+ observed.push(compiled.policy.name); // counted, didn't fire, fell through
863
+ }
864
+ }
700
865
  this.totalEvaluations++;
701
- if (!policy) {
866
+ if (!decision) {
867
+ decision = this.computeDecision(null, info);
702
868
  const bucket = this.defaultByEventType.get(info.eventType)
703
869
  ?? { triggered: 0, skipped: 0 };
704
870
  if (decision.trigger) {
@@ -711,6 +877,8 @@ export class EventGate {
711
877
  }
712
878
  this.defaultByEventType.set(info.eventType, bucket);
713
879
  }
880
+ if (observed.length > 0)
881
+ decision.observed = observed;
714
882
  this.emitTrace({
715
883
  type: 'gate:decision',
716
884
  eventType: info.eventType,
@@ -719,6 +887,7 @@ export class EventGate {
719
887
  matchedPolicy: decision.policyName,
720
888
  trigger: decision.trigger,
721
889
  behavior: formatBehavior(decision.behavior),
890
+ ...(observed.length > 0 ? { observed } : {}),
722
891
  timestamp: this.now(),
723
892
  });
724
893
  return decision;
@@ -866,6 +1035,147 @@ export class EventGate {
866
1035
  this.passiveSampleFires.delete(name);
867
1036
  }
868
1037
  // =========================================================================
1038
+ // Dry-run probes — side-effect-free "what would this event do right now"
1039
+ // =========================================================================
1040
+ /**
1041
+ * Non-mutating twin of the behavior decision: would this policy trigger for
1042
+ * this event, given CURRENT counter/bucket state? Reads state, never writes
1043
+ * it — no stats, no traces, no counter increments, no debounce batching.
1044
+ */
1045
+ wouldTrigger(policy, info) {
1046
+ const b = policy.behavior;
1047
+ if (b === 'always')
1048
+ return true;
1049
+ if (b === 'defer' || b === 'skip')
1050
+ return false;
1051
+ if (typeof b === 'object') {
1052
+ if ('debounce' in b)
1053
+ return false; // batches; never triggers inline
1054
+ if ('rate_limit' in b) {
1055
+ const cfg = b.rate_limit;
1056
+ const key = this.resolveKey(info.metadata, cfg.keyBy);
1057
+ const bucket = this.rateLimitBuckets.get(policy.name)?.get(key);
1058
+ if (!bucket)
1059
+ return true; // fresh bucket starts full
1060
+ let tokens = bucket.tokens;
1061
+ if (tokens < cfg.tokens) {
1062
+ const elapsed = Math.max(0, this.now() - bucket.lastRefill);
1063
+ tokens = Math.min(cfg.tokens, tokens + Math.floor(elapsed / cfg.refillIntervalMs));
1064
+ }
1065
+ return tokens > 0;
1066
+ }
1067
+ if ('passive_sample' in b) {
1068
+ const cfg = b.passive_sample;
1069
+ const key = this.resolveKey(info.metadata, cfg.keyBy);
1070
+ const count = this.passiveSampleCounters.get(policy.name)?.get(key) ?? 0;
1071
+ return count + 1 >= cfg.every;
1072
+ }
1073
+ }
1074
+ return false;
1075
+ }
1076
+ /**
1077
+ * Evaluate an event against the declarative policy table WITHOUT side
1078
+ * effects: no counters advance, no debounce batches form, no stats or
1079
+ * traces are recorded, and sleep suppression is ignored (a probe asks
1080
+ * about the table, not the moment). gate.js is not consulted — a script
1081
+ * may be stateful, and a probe must never perturb state.
1082
+ */
1083
+ probe(info) {
1084
+ for (const compiled of this.compiledPolicies) {
1085
+ if (!this.compiledMatches(compiled, info))
1086
+ continue;
1087
+ const trigger = this.wouldTrigger(compiled.policy, info);
1088
+ if (trigger || !compiled.policy.passthrough) {
1089
+ return { trigger, policyName: compiled.policy.name, behavior: compiled.policy.behavior };
1090
+ }
1091
+ }
1092
+ const trigger = (this.config.default ?? 'always') === 'always';
1093
+ return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
1094
+ }
1095
+ /**
1096
+ * Probe a canonical set of synthetic chat/heartbeat events and report which
1097
+ * rule wins each and whether it would wake. Backs the before/after table in
1098
+ * the wake_add_rule result — the point where a rule change's real semantics
1099
+ * must become legible (af#105; the Mythos stand-back-density incident would
1100
+ * have been visible as `dm: discord-direct-address → stand-back-density`).
1101
+ * Probes are representative shapes, not an exhaustive event space — rules
1102
+ * keyed on specific channels/mounts may not match any probe.
1103
+ */
1104
+ probeTable() {
1105
+ const chat = (label, eventType, metadata, tags) => ({
1106
+ label,
1107
+ info: {
1108
+ content: `[probe] ${label}`,
1109
+ eventType,
1110
+ serverId: 'discord',
1111
+ channelId: '',
1112
+ metadata: { ...metadata, eventType },
1113
+ tags,
1114
+ },
1115
+ });
1116
+ const probes = [
1117
+ chat('dm (open channel)', 'mcpl:channel-incoming', { isDM: true, isMention: true }, ['chat:dm', 'chat:private', 'chat:addressed', 'chat:from-human']),
1118
+ chat('dm (closed channel)', 'mcpl:push-event', { isDM: true, isMention: true }, ['chat:dm', 'chat:private', 'chat:addressed', 'chat:from-human']),
1119
+ chat('explicit mention (open channel)', 'mcpl:channel-incoming', { isMention: true, isExplicitMention: true }, ['chat:mention', 'chat:addressed', 'chat:from-human']),
1120
+ chat('explicit mention (closed channel)', 'mcpl:push-event', { isMention: true, isExplicitMention: true }, ['chat:mention', 'chat:addressed', 'chat:from-human']),
1121
+ chat('reply to you', 'mcpl:channel-incoming', { isMention: true, isReplyToBot: true }, ['chat:reply', 'chat:addressed', 'chat:from-human']),
1122
+ chat('ambient (human)', 'mcpl:channel-incoming', {}, ['chat:ambient', 'chat:from-human']),
1123
+ chat('ambient (bot)', 'mcpl:channel-incoming', { isBot: true }, ['chat:ambient', 'chat:from-bot']),
1124
+ {
1125
+ label: 'heartbeat',
1126
+ info: {
1127
+ content: '[probe] heartbeat',
1128
+ eventType: 'mcpl:push-event',
1129
+ serverId: 'heartbeat',
1130
+ channelId: '',
1131
+ metadata: { source: 'heartbeat', eventType: 'mcpl:push-event' },
1132
+ },
1133
+ },
1134
+ ];
1135
+ return probes.map(({ label, info }) => {
1136
+ const d = this.probe(info);
1137
+ return {
1138
+ probe: label,
1139
+ eventType: info.eventType,
1140
+ policy: d.policyName,
1141
+ behavior: d.policyName === null
1142
+ ? `default:${formatBehavior(d.behavior)}`
1143
+ : formatBehavior(d.behavior),
1144
+ wouldWake: d.trigger,
1145
+ };
1146
+ });
1147
+ }
1148
+ // =========================================================================
1149
+ // Shadow lint surface
1150
+ // =========================================================================
1151
+ /** Current shadow-lint findings for the live policy order. Fresh compute —
1152
+ * cheap (policy lists are tens of entries at most). */
1153
+ lintShadows() {
1154
+ return findShadowedPolicies(this.config.policies);
1155
+ }
1156
+ /**
1157
+ * Recompute shadow warnings and, when the set changed and is non-empty,
1158
+ * emit a `gate:shadow-warnings` trace so the hazard lands in ops
1159
+ * observability at the moment the order changed — not at the next missed
1160
+ * DM. Called from writeConfig and reloadIfChanged.
1161
+ */
1162
+ refreshShadowWarnings(reason) {
1163
+ const warnings = this.lintShadows();
1164
+ const fingerprint = JSON.stringify(warnings);
1165
+ if (fingerprint === this.shadowWarningsFingerprint)
1166
+ return;
1167
+ this.shadowWarningsFingerprint = fingerprint;
1168
+ this.shadowWarningsCache = warnings;
1169
+ if (warnings.length > 0) {
1170
+ this.emitTrace({
1171
+ type: 'gate:shadow-warnings',
1172
+ reason,
1173
+ warnings: warnings.map(formatShadowWarning),
1174
+ timestamp: this.now(),
1175
+ });
1176
+ }
1177
+ }
1178
+ // =========================================================================
869
1179
  // shouldTriggerInference callback adapter
870
1180
  // =========================================================================
871
1181
  /**
@@ -1269,6 +1579,7 @@ export class EventGate {
1269
1579
  script: this.gateScript.status(),
1270
1580
  policies,
1271
1581
  errors: [...this.configErrors],
1582
+ shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
1272
1583
  totalEvaluations: this.totalEvaluations,
1273
1584
  defaultDecisions: {
1274
1585
  triggered: this.defaultTriggered,